Three issues surfaced when running the MV smoke tests against westin-v19: 1. account_bank_statement_line has no `date` column in V19 — `date` is a related field flowing through move_id -> account_move.date. The MV now JOINs account_move and selects am.date. 2. is_reconciled is nullable; replace `= FALSE` with `IS NOT TRUE` so nulls (genuinely unreconciled lines that haven't had the compute run yet) are still included. 3. _refresh() now flushes the ORM cache (env.flush_all()) before the REFRESH so computed-stored fields like is_reconciled are written to the DB before the materialization snapshot reads them. Previously the reconcile-then-refresh path saw the pre-reconcile column value. 4. _trigger_mv_refresh() (suggestion create/write hook) now uses concurrently=False because Postgres forbids REFRESH MATERIALIZED VIEW CONCURRENTLY inside a transaction block, and Odoo's per-request cursor is always inside one. The cron path (Task 25) will open an autocommit cursor for CONCURRENTLY refreshes. 5. Tests dropped the env.cr.commit() pattern: Postgres always shows a transaction its own writes, so a non-CONCURRENTLY refresh in the same txn picks up freshly-inserted rows. Cleaner + works inside TransactionCase, which forbids cr.commit(). Verified: 4 new MV tests pass, 0 failures across 118 logical tests (178 with parametrized property-based runs) of fusion_accounting_bank_rec on westin-v19. Made-with: Cursor
138 lines
5.3 KiB
Python
138 lines
5.3 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.
|
|
"""
|
|
|
|
import logging
|
|
|
|
from odoo import api, fields, models
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
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'
|
|
|
|
# ------------------------------------------------------------------
|
|
# CRUD overrides — trigger MV refresh so the OWL widget sees fresh
|
|
# confidence bands / top suggestion ids without waiting for cron.
|
|
# ------------------------------------------------------------------
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
records = super().create(vals_list)
|
|
self._trigger_mv_refresh()
|
|
return records
|
|
|
|
def write(self, vals):
|
|
res = super().write(vals)
|
|
# Only refresh on changes that affect the MV's projected columns.
|
|
if 'state' in vals or 'confidence' in vals or 'rank' in vals:
|
|
self._trigger_mv_refresh()
|
|
return res
|
|
|
|
def _trigger_mv_refresh(self):
|
|
"""Best-effort MV refresh; never poison the originating transaction.
|
|
|
|
Uses concurrently=False because Postgres forbids
|
|
REFRESH MATERIALIZED VIEW CONCURRENTLY inside a transaction block,
|
|
and Odoo's per-request cursor is always in a transaction. The cron
|
|
job (Task 25) opens a dedicated autocommit cursor for CONCURRENTLY
|
|
refreshes when the MV grows large enough that a brief blocking
|
|
refresh becomes objectionable.
|
|
"""
|
|
try:
|
|
self.env['fusion.unreconciled.bank.line.mv']._refresh(
|
|
concurrently=False)
|
|
except Exception as e: # noqa: BLE001
|
|
_logger.warning(
|
|
"MV refresh after suggestion write failed: %s", e)
|