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',
|
'name': 'Fusion Accounting — Bank Reconciliation',
|
||||||
'version': '19.0.1.0.1',
|
'version': '19.0.1.0.2',
|
||||||
'category': 'Accounting/Accounting',
|
'category': 'Accounting/Accounting',
|
||||||
'sequence': 28,
|
'sequence': 28,
|
||||||
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',
|
'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_pattern
|
||||||
from . import fusion_reconcile_precedent
|
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_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_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_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_memo_tokenizer
|
||||||
from . import test_exchange_diff
|
from . import test_exchange_diff
|
||||||
from . import test_matching_strategies
|
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