Files
Odoo-Modules/fusion_accounting_bank_rec/models/fusion_reconcile_suggestion.py
gsinghpal d953525758 fix(fusion_accounting_bank_rec): MV correctness for V19 schema + Odoo test harness
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
2026-04-19 11:51:02 -04:00

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)