feat(fusion_accounting_bank_rec): persisted AI suggestion model with state lifecycle
Made-with: Cursor
This commit is contained in:
@@ -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.',
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from . import fusion_reconcile_pattern
|
||||
from . import fusion_reconcile_precedent
|
||||
from . import fusion_reconcile_suggestion
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user