From 267c8ee16566dc054389e74f4d49fb8414731722 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 10:20:10 -0400 Subject: [PATCH] feat(fusion_accounting_bank_rec): persisted AI suggestion model with state lifecycle Made-with: Cursor --- fusion_accounting_bank_rec/__manifest__.py | 2 +- fusion_accounting_bank_rec/models/__init__.py | 1 + .../models/fusion_reconcile_suggestion.py | 98 +++++++++++++++++++ .../security/ir.model.access.csv | 2 + fusion_accounting_bank_rec/tests/__init__.py | 1 + .../tests/test_ai_suggestion_lifecycle.py | 86 ++++++++++++++++ 6 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_bank_rec/models/fusion_reconcile_suggestion.py create mode 100644 fusion_accounting_bank_rec/tests/test_ai_suggestion_lifecycle.py diff --git a/fusion_accounting_bank_rec/__manifest__.py b/fusion_accounting_bank_rec/__manifest__.py index bceaa881..df5fc272 100644 --- a/fusion_accounting_bank_rec/__manifest__.py +++ b/fusion_accounting_bank_rec/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting — Bank Reconciliation', - 'version': '19.0.1.0.1', + 'version': '19.0.1.0.2', 'category': 'Accounting/Accounting', 'sequence': 28, 'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.', diff --git a/fusion_accounting_bank_rec/models/__init__.py b/fusion_accounting_bank_rec/models/__init__.py index e8bf5bcf..07f30a4a 100644 --- a/fusion_accounting_bank_rec/models/__init__.py +++ b/fusion_accounting_bank_rec/models/__init__.py @@ -1,2 +1,3 @@ from . import fusion_reconcile_pattern from . import fusion_reconcile_precedent +from . import fusion_reconcile_suggestion diff --git a/fusion_accounting_bank_rec/models/fusion_reconcile_suggestion.py b/fusion_accounting_bank_rec/models/fusion_reconcile_suggestion.py new file mode 100644 index 00000000..29d64478 --- /dev/null +++ b/fusion_accounting_bank_rec/models/fusion_reconcile_suggestion.py @@ -0,0 +1,98 @@ +"""Persisted AI suggestions for bank line reconciliations. + +One row per (statement_line, candidate_match). The OWL widget reads these +to render confidence badges; users accept/reject which feeds back into +the pattern learning system. + +The AI never writes account.partial.reconcile directly — it writes +suggestions here, and the user (or batch-accept action) approves them +through the engine's accept_suggestion() method. +""" + +from odoo import api, fields, models + + +class FusionReconcileSuggestion(models.Model): + _name = "fusion.reconcile.suggestion" + _description = "AI-generated bank reconciliation suggestion" + _order = "statement_line_id, confidence desc" + + company_id = fields.Many2one('res.company', required=True, index=True, + default=lambda self: self.env.company) + statement_line_id = fields.Many2one('account.bank.statement.line', + required=True, index=True, ondelete='cascade') + + # The proposal + proposed_move_line_ids = fields.Many2many('account.move.line', + string="Proposed matches") + proposed_write_off_amount = fields.Monetary(currency_field='currency_id') + proposed_write_off_account_id = fields.Many2one('account.account') + currency_id = fields.Many2one('res.currency', + related='statement_line_id.currency_id', + store=True) + + # Scoring + confidence = fields.Float(required=True) + confidence_band = fields.Selection([ + ('high', 'High (>=95%)'), + ('medium', 'Medium (70-94%)'), + ('low', 'Low (50-69%)'), + ('none', 'No confidence (<50%)'), + ], compute='_compute_band', store=True) + rank = fields.Integer(help="1 = top suggestion, 2-N = alternatives") + reasoning = fields.Text(help="Human-readable explanation") + + # Feature breakdown (for transparency + future learning) + score_amount_match = fields.Float() + score_partner_pattern = fields.Float() + score_precedent_similarity = fields.Float() + score_ai_rerank = fields.Float() + + # Provenance + generated_at = fields.Datetime(default=fields.Datetime.now) + generated_by = fields.Selection([ + ('cron_batch', 'Batch cron'), + ('on_demand', 'User refreshed alternatives'), + ('on_open', 'Widget opened (lazy)'), + ]) + provider_used = fields.Char( + help="e.g. 'claude_sonnet_4_5', 'lmstudio_qwen_7b', 'statistical_only'") + tokens_used = fields.Integer(help="if AI re-rank invoked") + generation_ms = fields.Integer(help="latency for monitoring") + + # Lifecycle + state = fields.Selection([ + ('pending', 'Pending review'), + ('accepted', 'Accepted'), + ('rejected', 'Rejected'), + ('superseded', 'Superseded by newer suggestion'), + ('stale', 'Stale (line changed since)'), + ], default='pending', required=True, index=True) + accepted_at = fields.Datetime() + accepted_by = fields.Many2one('res.users') + rejected_at = fields.Datetime() + rejected_reason = fields.Selection([ + ('wrong_invoice', 'Wrong invoice'), + ('wrong_partner', 'Wrong partner'), + ('wrong_amount', 'Amount off'), + ('not_a_match', 'No good match exists'), + ('other', 'Other'), + ]) + + _confidence_in_range = models.Constraint( + 'CHECK (confidence >= 0.0 AND confidence <= 1.0)', + 'Confidence must be between 0.0 and 1.0', + ) + + @api.depends('confidence') + def _compute_band(self): + for sug in self: + c = sug.confidence + if c >= 0.95: + sug.confidence_band = 'high' + elif c >= 0.70: + sug.confidence_band = 'medium' + elif c >= 0.50: + sug.confidence_band = 'low' + else: + sug.confidence_band = 'none' diff --git a/fusion_accounting_bank_rec/security/ir.model.access.csv b/fusion_accounting_bank_rec/security/ir.model.access.csv index 62975dd9..86831cac 100644 --- a/fusion_accounting_bank_rec/security/ir.model.access.csv +++ b/fusion_accounting_bank_rec/security/ir.model.access.csv @@ -3,3 +3,5 @@ access_fusion_reconcile_pattern_user,pattern user,model_fusion_reconcile_pattern access_fusion_reconcile_pattern_admin,pattern admin,model_fusion_reconcile_pattern,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 access_fusion_reconcile_precedent_user,precedent user,model_fusion_reconcile_precedent,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0 access_fusion_reconcile_precedent_admin,precedent admin,model_fusion_reconcile_precedent,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 +access_fusion_reconcile_suggestion_user,suggestion user,model_fusion_reconcile_suggestion,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0 +access_fusion_reconcile_suggestion_admin,suggestion admin,model_fusion_reconcile_suggestion,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 diff --git a/fusion_accounting_bank_rec/tests/__init__.py b/fusion_accounting_bank_rec/tests/__init__.py index 223da87a..860a17b7 100644 --- a/fusion_accounting_bank_rec/tests/__init__.py +++ b/fusion_accounting_bank_rec/tests/__init__.py @@ -1,3 +1,4 @@ from . import test_memo_tokenizer from . import test_exchange_diff from . import test_matching_strategies +from . import test_ai_suggestion_lifecycle diff --git a/fusion_accounting_bank_rec/tests/test_ai_suggestion_lifecycle.py b/fusion_accounting_bank_rec/tests/test_ai_suggestion_lifecycle.py new file mode 100644 index 00000000..b1233702 --- /dev/null +++ b/fusion_accounting_bank_rec/tests/test_ai_suggestion_lifecycle.py @@ -0,0 +1,86 @@ +from odoo.tests.common import TransactionCase, tagged + + +@tagged('post_install', '-at_install') +class TestSuggestionLifecycle(TransactionCase): + """The fusion.reconcile.suggestion state machine + computed band.""" + + def setUp(self): + super().setUp() + journal = self.env['account.journal'].create({ + 'name': 'Test Bank Suggestion', + 'type': 'bank', + 'code': 'TBSG', + }) + statement = self.env['account.bank.statement'].create({ + 'name': 'Test Statement', + 'journal_id': journal.id, + }) + self.line = self.env['account.bank.statement.line'].create({ + 'statement_id': statement.id, + 'journal_id': journal.id, + 'date': '2026-04-19', + 'payment_ref': 'Test for suggestion', + 'amount': 100.00, + }) + + def _make_suggestion(self, confidence=0.92, **vals): + defaults = { + 'company_id': self.env.company.id, + 'statement_line_id': self.line.id, + 'confidence': confidence, + 'rank': 1, + 'reasoning': 'Test', + } + defaults.update(vals) + return self.env['fusion.reconcile.suggestion'].create(defaults) + + def test_compute_band_high(self): + sug = self._make_suggestion(confidence=0.96) + self.assertEqual(sug.confidence_band, 'high') + + def test_compute_band_medium(self): + sug = self._make_suggestion(confidence=0.75) + self.assertEqual(sug.confidence_band, 'medium') + + def test_compute_band_low(self): + sug = self._make_suggestion(confidence=0.55) + self.assertEqual(sug.confidence_band, 'low') + + def test_compute_band_none(self): + sug = self._make_suggestion(confidence=0.30) + self.assertEqual(sug.confidence_band, 'none') + + def test_default_state_is_pending(self): + sug = self._make_suggestion() + self.assertEqual(sug.state, 'pending') + + def test_state_transition_to_accepted(self): + sug = self._make_suggestion() + sug.write({ + 'state': 'accepted', + 'accepted_at': '2026-04-19 12:00:00', + 'accepted_by': self.env.user.id, + }) + self.assertEqual(sug.state, 'accepted') + self.assertTrue(sug.accepted_at) + self.assertEqual(sug.accepted_by, self.env.user) + + def test_state_transition_to_rejected_with_reason(self): + sug = self._make_suggestion() + sug.write({ + 'state': 'rejected', + 'rejected_at': '2026-04-19 12:05:00', + 'rejected_reason': 'wrong_invoice', + }) + self.assertEqual(sug.state, 'rejected') + self.assertEqual(sug.rejected_reason, 'wrong_invoice') + + def test_state_transition_to_superseded(self): + sug = self._make_suggestion() + sug.write({'state': 'superseded'}) + self.assertEqual(sug.state, 'superseded') + + def test_currency_id_relates_to_line(self): + sug = self._make_suggestion() + self.assertEqual(sug.currency_id, self.line.currency_id)