From c20e0888e1776b1b6db1f0df862fcc9427e4e4c5 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 15:32:09 -0400 Subject: [PATCH] feat(fusion_accounting_reports): fusion.report.anomaly persisted model Made-with: Cursor --- fusion_accounting_reports/__manifest__.py | 2 +- fusion_accounting_reports/models/__init__.py | 1 + .../models/fusion_report_anomaly.py | 56 +++++++++++++++++++ .../security/ir.model.access.csv | 1 + fusion_accounting_reports/tests/__init__.py | 1 + .../tests/test_fusion_report_anomaly.py | 52 +++++++++++++++++ 6 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_reports/models/fusion_report_anomaly.py create mode 100644 fusion_accounting_reports/tests/test_fusion_report_anomaly.py diff --git a/fusion_accounting_reports/__manifest__.py b/fusion_accounting_reports/__manifest__.py index b0d633b0..83bd3381 100644 --- a/fusion_accounting_reports/__manifest__.py +++ b/fusion_accounting_reports/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting Reports', - 'version': '19.0.1.0.12', + 'version': '19.0.1.0.13', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).', 'description': """ diff --git a/fusion_accounting_reports/models/__init__.py b/fusion_accounting_reports/models/__init__.py index 0e9fa527..2bd452d8 100644 --- a/fusion_accounting_reports/models/__init__.py +++ b/fusion_accounting_reports/models/__init__.py @@ -1,3 +1,4 @@ from . import fusion_report from . import fusion_report_engine from . import fusion_report_commentary +from . import fusion_report_anomaly diff --git a/fusion_accounting_reports/models/fusion_report_anomaly.py b/fusion_accounting_reports/models/fusion_report_anomaly.py new file mode 100644 index 00000000..5b8489c9 --- /dev/null +++ b/fusion_accounting_reports/models/fusion_report_anomaly.py @@ -0,0 +1,56 @@ +"""Persisted anomaly flags from the engine's variance detection. + +Each row captures one flagged report row variance. Used by the OWL +anomaly_strip + the audit trail.""" + +from odoo import _, api, fields, models + + +SEVERITY = [('low', 'Low'), ('medium', 'Medium'), ('high', 'High')] +DIRECTION = [('increase', 'Increase'), ('decrease', 'Decrease')] + + +class FusionReportAnomaly(models.Model): + _name = "fusion.report.anomaly" + _description = "Flagged Report Variance" + _order = "detected_at desc, severity desc" + + report_id = fields.Many2one('fusion.report', required=True, ondelete='cascade') + company_id = fields.Many2one('res.company', required=True, + default=lambda self: self.env.company) + period_from = fields.Date(required=True) + period_to = fields.Date(required=True) + + row_id = fields.Char(required=True, help="Engine-generated row id (e.g. 'line_3').") + label = fields.Char(required=True) + current_amount = fields.Float() + comparison_amount = fields.Float() + variance_amount = fields.Float() + variance_pct = fields.Float() + severity = fields.Selection(SEVERITY, required=True) + direction = fields.Selection(DIRECTION, required=True) + + detected_at = fields.Datetime(default=fields.Datetime.now, required=True) + state = fields.Selection([ + ('new', 'New'), + ('acknowledged', 'Acknowledged'), + ('investigating', 'Investigating'), + ('resolved', 'Resolved'), + ('dismissed', 'Dismissed'), + ], default='new', required=True) + notes = fields.Text() + acknowledged_by = fields.Many2one('res.users') + acknowledged_at = fields.Datetime() + + def action_acknowledge(self): + self.write({ + 'state': 'acknowledged', + 'acknowledged_by': self.env.uid, + 'acknowledged_at': fields.Datetime.now(), + }) + + def action_dismiss(self): + self.write({'state': 'dismissed'}) + + def action_resolve(self): + self.write({'state': 'resolved'}) diff --git a/fusion_accounting_reports/security/ir.model.access.csv b/fusion_accounting_reports/security/ir.model.access.csv index e5ffcdcb..83c075b2 100644 --- a/fusion_accounting_reports/security/ir.model.access.csv +++ b/fusion_accounting_reports/security/ir.model.access.csv @@ -2,3 +2,4 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_fusion_report_user,fusion.report.user,model_fusion_report,base.group_user,1,0,0,0 access_fusion_report_admin,fusion.report.admin,model_fusion_report,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 access_fusion_report_commentary,fusion.report.commentary,model_fusion_report_commentary,base.group_user,1,1,1,0 +access_fusion_report_anomaly,fusion.report.anomaly,model_fusion_report_anomaly,base.group_user,1,1,1,0 diff --git a/fusion_accounting_reports/tests/__init__.py b/fusion_accounting_reports/tests/__init__.py index decddc27..62cb7f71 100644 --- a/fusion_accounting_reports/tests/__init__.py +++ b/fusion_accounting_reports/tests/__init__.py @@ -9,3 +9,4 @@ from . import test_anomaly_detection from . import test_commentary_prompt from . import test_commentary_generator from . import test_fusion_report_commentary +from . import test_fusion_report_anomaly diff --git a/fusion_accounting_reports/tests/test_fusion_report_anomaly.py b/fusion_accounting_reports/tests/test_fusion_report_anomaly.py new file mode 100644 index 00000000..fb831342 --- /dev/null +++ b/fusion_accounting_reports/tests/test_fusion_report_anomaly.py @@ -0,0 +1,52 @@ +"""Tests for fusion.report.anomaly model.""" + +from datetime import date +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestFusionReportAnomaly(TransactionCase): + + def setUp(self): + super().setUp() + self.report = self.env.ref('fusion_accounting_reports.report_pnl') + + def _make(self, **vals): + defaults = { + 'report_id': self.report.id, + 'period_from': date(2026, 4, 1), + 'period_to': date(2026, 4, 30), + 'row_id': 'line_0', + 'label': 'Revenue', + 'current_amount': 12000, + 'comparison_amount': 10000, + 'variance_amount': 2000, + 'variance_pct': 20.0, + 'severity': 'medium', + 'direction': 'increase', + } + defaults.update(vals) + return self.env['fusion.report.anomaly'].create(defaults) + + def test_create_basic(self): + a = self._make() + self.assertEqual(a.severity, 'medium') + self.assertEqual(a.state, 'new') + self.assertTrue(a.detected_at) + + def test_acknowledge_action(self): + a = self._make() + a.action_acknowledge() + self.assertEqual(a.state, 'acknowledged') + self.assertEqual(a.acknowledged_by, self.env.user) + self.assertTrue(a.acknowledged_at) + + def test_dismiss_action(self): + a = self._make() + a.action_dismiss() + self.assertEqual(a.state, 'dismissed') + + def test_resolve_action(self): + a = self._make() + a.action_resolve() + self.assertEqual(a.state, 'resolved')