99 lines
3.8 KiB
Python
99 lines
3.8 KiB
Python
"""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'
|