This commit is contained in:
gsinghpal
2026-05-16 13:18:52 -04:00
parent 191a9c82be
commit 9ebf89bde2
1080 changed files with 0 additions and 1197 deletions

View File

@@ -0,0 +1,10 @@
from . import fusion_reconcile_pattern
from . import fusion_reconcile_precedent
from . import fusion_reconcile_suggestion
from . import fusion_bank_rec_widget
from . import account_bank_statement_line
from . import account_reconcile_model
from . import fusion_reconcile_engine
from . import fusion_unreconciled_bank_line_mv
from . import fusion_bank_rec_cron
from . import fusion_migration_wizard

View File

@@ -0,0 +1,52 @@
"""Inherit account.bank.statement.line to add Phase 1 widget compute fields.
These fields are NOT stored — they're computed on-the-fly so the OWL widget
can render confidence badges without round-tripping. Performance OK because
the widget loads ~50-200 lines per kanban open and each compute is a single
indexed query into fusion.reconcile.suggestion.
"""
from odoo import api, fields, models
class AccountBankStatementLine(models.Model):
_inherit = "account.bank.statement.line"
# Top suggestion + its band — for the inline AI confidence badge
fusion_top_suggestion_id = fields.Many2one(
'fusion.reconcile.suggestion',
compute='_compute_top_suggestion',
store=False,
help="Highest-ranked pending AI suggestion for this line")
fusion_confidence_band = fields.Selection(
[('high', 'High'), ('medium', 'Medium'), ('low', 'Low'), ('none', 'None')],
compute='_compute_top_suggestion',
store=False,
default='none',
help="Quick-render colour band for the OWL widget badge")
# Mirror of Enterprise's bank_statement_attachment_ids surface field.
# Defined here so fusion's widget can render attachments without
# depending on account_accountant being installed.
bank_statement_attachment_ids = fields.One2many(
'ir.attachment',
compute='_compute_bank_statement_attachment_ids',
help="Attachments on the underlying account.move; mirrored for the OWL widget")
def _compute_top_suggestion(self):
Suggestion = self.env['fusion.reconcile.suggestion'].sudo()
for line in self:
top = Suggestion.search([
('statement_line_id', '=', line.id),
('state', '=', 'pending'),
('rank', '=', 1),
], limit=1)
line.fusion_top_suggestion_id = top
line.fusion_confidence_band = top.confidence_band if top else 'none'
@api.depends('move_id', 'move_id.attachment_ids')
def _compute_bank_statement_attachment_ids(self):
for line in self:
line.bank_statement_attachment_ids = (
line.move_id.attachment_ids if line.move_id else self.env['ir.attachment']
)

View File

@@ -0,0 +1,20 @@
"""Inherit account.reconcile.model to add Phase 1 AI integration hooks.
This is a minimal extension placeholder for now — Phase 1+ phases may
expand it (e.g., to attach AI confidence rules to reconcile-model
auto-fires). The shared-field-ownership for `created_automatically`
already lives in fusion_accounting_core; this file is for fusion_bank_rec
specific extensions only.
"""
from odoo import fields, models
class AccountReconcileModel(models.Model):
_inherit = "account.reconcile.model"
fusion_ai_confidence_threshold = fields.Float(
string="AI confidence threshold",
default=0.0,
help="If >0.0, fusion AI suggestions matching this rule are auto-applied "
"only when their confidence ≥ this threshold. 0.0 = no AI filtering.")

View File

@@ -0,0 +1,119 @@
"""Cron handler model for fusion_accounting_bank_rec.
Three scheduled jobs:
- _cron_suggest_pending: warm AI suggestions for unreconciled lines (30 min)
- _cron_refresh_patterns: recompute fusion.reconcile.pattern aggregates (daily 02:00)
- _cron_refresh_mv: REFRESH MATERIALIZED VIEW CONCURRENTLY (5 min)
"""
import logging
from datetime import timedelta
import odoo
from odoo import api, fields, models
from ..services.pattern_extractor import extract_pattern_for_partner
_logger = logging.getLogger(__name__)
class FusionBankRecCron(models.AbstractModel):
_name = "fusion.bank.rec.cron"
_description = "Fusion Bank Reconciliation Cron Handlers"
@api.model
def _cron_suggest_pending(self, batch_size=50):
"""For each unreconciled bank line that doesn't have a recent pending
suggestion, run engine.suggest_matches.
Recent = a pending suggestion created within the last 24 hours."""
cutoff = fields.Datetime.now() - timedelta(hours=24)
Line = self.env['account.bank.statement.line']
lines_to_consider = Line.search([
('is_reconciled', '=', False),
('partner_id', '!=', False),
], limit=batch_size * 5)
Suggestion = self.env['fusion.reconcile.suggestion']
lines_needing_suggestions = self.env['account.bank.statement.line']
for line in lines_to_consider:
recent = Suggestion.search_count([
('statement_line_id', '=', line.id),
('state', '=', 'pending'),
('create_date', '>=', cutoff),
])
if recent == 0:
lines_needing_suggestions |= line
if len(lines_needing_suggestions) >= batch_size:
break
if not lines_needing_suggestions:
_logger.debug("Cron: no bank lines need suggestion warming")
return
_logger.info(
"Cron: warming suggestions for %d bank lines",
len(lines_needing_suggestions))
try:
self.env['fusion.reconcile.engine'].suggest_matches(
lines_needing_suggestions, limit_per_line=3)
except Exception as e:
_logger.exception("Cron suggest_pending failed: %s", e)
@api.model
def _cron_refresh_patterns(self):
"""For each (company, partner) pair with precedents, recompute and
upsert the fusion.reconcile.pattern row."""
Pattern = self.env['fusion.reconcile.pattern']
self.env.cr.execute("""
SELECT DISTINCT company_id, partner_id
FROM fusion_reconcile_precedent
WHERE partner_id IS NOT NULL
""")
pairs = self.env.cr.fetchall()
_logger.info(
"Cron: refreshing patterns for %d (company, partner) pairs",
len(pairs))
for company_id, partner_id in pairs:
try:
vals = extract_pattern_for_partner(
self.env, company_id=company_id, partner_id=partner_id)
existing = Pattern.search([
('company_id', '=', company_id),
('partner_id', '=', partner_id),
], limit=1)
if existing:
existing.write(vals)
else:
Pattern.create(vals)
except Exception as e:
_logger.warning(
"Pattern refresh failed for company=%s partner=%s: %s",
company_id, partner_id, e)
@api.model
def _cron_refresh_mv(self):
"""Refresh the materialized view CONCURRENTLY using an autocommit cursor.
REFRESH CONCURRENTLY can't run inside a transaction, so we open a
fresh connection in autocommit mode (per Task 24's note). On any
failure, we fall back to the model's blocking refresh."""
try:
db_name = self.env.cr.dbname
db = odoo.sql_db.db_connect(db_name)
with db.cursor() as cron_cr:
cron_cr._cnx.set_session(autocommit=True)
cron_cr.execute(
"REFRESH MATERIALIZED VIEW CONCURRENTLY "
"fusion_unreconciled_bank_line_mv")
_logger.debug("Cron: MV refresh CONCURRENTLY succeeded")
except Exception as e:
_logger.warning(
"Cron MV refresh CONCURRENTLY failed (%s); falling back to "
"blocking refresh", e)
try:
self.env['fusion.unreconciled.bank.line.mv']._refresh(
concurrently=False)
except Exception as e2:
_logger.exception(
"Cron MV refresh fallback also failed: %s", e2)

View File

@@ -0,0 +1,33 @@
"""Per-request widget state. Holds the kanban-load response shape so the
controller can return one well-typed object.
This is a TransientModel (no DB persistence beyond the request). The OWL
widget reads pre-computed fusion.reconcile.suggestion rows directly via
the controller; this model is just a typed envelope for the kanban-open
action."""
from odoo import api, fields, models
class FusionBankRecWidget(models.TransientModel):
_name = "fusion.bank.rec.widget"
_description = "Bank reconciliation widget state (transient)"
journal_id = fields.Many2one('account.journal',
domain="[('type', '=', 'bank')]")
statement_line_ids = fields.Many2many('account.bank.statement.line')
summary_count = fields.Integer(
help="Number of unreconciled lines visible in this widget")
summary_unreconciled_balance = fields.Monetary(currency_field='currency_id')
currency_id = fields.Many2one('res.currency',
related='journal_id.currency_id',
store=False, readonly=True)
def action_open_kanban(self):
"""Return a window action opening the OWL kanban for this journal."""
self.ensure_one()
return {
'type': 'ir.actions.client',
'tag': 'fusion_bank_rec_kanban',
'params': {'journal_id': self.journal_id.id},
}

View File

@@ -0,0 +1,99 @@
"""Bank-rec specific migration step.
Hooks into fusion.migration.wizard (defined by fusion_accounting_migration)
to bootstrap fusion.reconcile.precedent from existing
account.partial.reconcile rows. This gives the AI immediate "memory" from
past Enterprise reconciles so suggestions can be ranked by precedent
similarity from day one.
The bootstrap step is exposed as a public method (_bank_rec_bootstrap_step)
so tests and the audit report can invoke it directly. action_run_migration
is overridden to call super() then run the bootstrap.
"""
import logging
from odoo import _, models
from ..services.precedent_backfill import backfill_precedents
_logger = logging.getLogger(__name__)
class FusionMigrationWizard(models.TransientModel):
_inherit = "fusion.migration.wizard"
def _bank_rec_bootstrap_step(self):
"""Migration step: backfill precedents + refresh patterns + refresh MV.
Returns a dict describing what happened, suitable for surfacing to
the user via notification or PDF audit report.
"""
self.ensure_one()
_logger.info(
"fusion_accounting_bank_rec migration step: bootstrap starting")
company_id = None
if 'company_id' in self._fields and self.company_id:
company_id = self.company_id.id
precedent_result = backfill_precedents(
self.env, company_id=company_id, limit=10000)
try:
self.env['fusion.bank.rec.cron']._cron_refresh_patterns()
patterns_ok = True
except Exception as e: # noqa: BLE001
_logger.warning(
"Pattern refresh during migration failed: %s", e)
patterns_ok = False
try:
self.env['fusion.unreconciled.bank.line.mv']._refresh(
concurrently=False)
mv_ok = True
except Exception as e: # noqa: BLE001
_logger.warning("MV refresh during migration failed: %s", e)
mv_ok = False
result = {
'step': 'bank_rec_bootstrap',
'precedents_created': precedent_result['created'],
'precedents_skipped': precedent_result['skipped'],
'patterns_refreshed': patterns_ok,
'mv_refreshed': mv_ok,
}
_logger.info(
"fusion_accounting_bank_rec bootstrap complete: %s", result)
return result
def action_run_migration(self):
"""Override the migration entry-point to add the bank-rec step.
Calls super() (which currently returns a notification stub from
Phase 0) and then runs the bank-rec bootstrap. Returns a
notification summarizing both.
"""
# Don't bind super()'s return value to `_` \u2014 that shadows the
# imported translation function and breaks the _("...") calls below.
super().action_run_migration()
result = self._bank_rec_bootstrap_step()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'success',
'title': _("Bank-Rec Migration Complete"),
'message': _(
"Backfilled %(created)d precedents "
"(skipped %(skipped)d). "
"Patterns refreshed: %(p)s. MV refreshed: %(m)s."
) % {
'created': result['precedents_created'],
'skipped': result['precedents_skipped'],
'p': 'yes' if result['patterns_refreshed'] else 'no',
'm': 'yes' if result['mv_refreshed'] else 'no',
},
'sticky': False,
},
}

View File

@@ -0,0 +1,481 @@
"""The reconcile engine — orchestrator for all bank-line reconciliations.
Public API: 6 methods. All other code (controllers, AI tools, wizards)
must go through these methods; no direct ORM writes to
``account.partial.reconcile`` from anywhere else.
V19 mechanics (per Enterprise's bank_rec_widget pattern):
A bank statement line creates an ``account.move`` with two journal
items: a *liquidity* line on the journal's default account, and a
*suspense* line on the journal's suspense account. Reconciliation
replaces the suspense line with one or more *counterpart* lines posted
to the matched invoices' receivable / payable accounts (or the write-off
account), then calls Odoo's standard ``account.move.line.reconcile()``
on each counterpart + invoice pair.
Internal pipeline (per spec Section 3.3):
1. Validate (period not locked, mandatory args present).
2. Compute counterpart vals from ``against_lines`` and optional write-off.
3. Rewrite the bank move ``line_ids``: keep liquidity, drop suspense +
any prior other lines, append the new counterparts.
4. Reconcile each counterpart with its matched invoice line.
5. Audit (``mail.message``) + record precedent for future learning.
"""
import logging
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.fields import Command
from ..services.matching_strategies import (
AmountExactStrategy,
Candidate,
FIFOStrategy,
MultiInvoiceStrategy,
)
from ..services.confidence_scoring import score_candidates
from ..services.memo_tokenizer import tokenize_memo
_logger = logging.getLogger(__name__)
class FusionReconcileEngine(models.AbstractModel):
_name = "fusion.reconcile.engine"
_description = "Fusion Bank Reconciliation Engine"
# ============================================================
# PUBLIC API (6 methods)
# ============================================================
@api.model
def reconcile_one(self, statement_line, *, against_lines=None,
write_off_vals=None):
"""Reconcile one bank line against a set of journal items.
Returns: ``{'partial_ids': [...], 'exchange_diff_move_id': int|None,
'write_off_move_id': int|None}``
"""
if not statement_line:
raise ValidationError(_("statement_line is required"))
statement_line.ensure_one()
AML = self.env['account.move.line']
against_lines = against_lines or AML
if not against_lines and not write_off_vals:
raise ValidationError(
_("Either against_lines or write_off_vals required"))
self._validate_reconcile(statement_line, against_lines)
bank_move = statement_line.move_id
liquidity_lines, suspense_lines, other_lines = (
statement_line._seek_for_lines())
# The bank move must stay balanced after we rewrite line_ids.
# Liquidity sums to +bank_amount (or -bank_amount for outbound), so
# the new counterparts must sum to the inverse. We allocate the
# available bank amount across against_lines, clamped to each
# invoice's residual; any leftover goes to the write-off line (or
# raises if no write-off was requested).
liq_balance = sum(liquidity_lines.mapped('balance'))
# Available counterpart balance (positive magnitude) = abs(liq_balance)
remaining = abs(liq_balance)
# Counterparts mirror liquidity: opposite sign of liq_balance.
cp_sign = -1 if liq_balance >= 0 else 1
new_counterpart_vals = []
for inv_line in against_lines:
inv_residual = inv_line.amount_residual
# Clamp so we never write more than the invoice residual nor more
# than what the bank line can pay.
allocate = min(remaining, abs(inv_residual))
new_counterpart_vals.append(self._build_counterpart_vals(
statement_line, inv_line,
allocated_balance=cp_sign * allocate,
))
remaining -= allocate
if remaining <= 0:
break
write_off_move_id = None
if write_off_vals:
# Write-off absorbs whatever the against_lines didn't cover.
wo_balance = cp_sign * remaining
# If user passed an explicit amount and there are no against_lines,
# honour the explicit amount (covers the pure write-off case).
if (write_off_vals.get('amount') is not None
and not against_lines):
wo_balance = -write_off_vals['amount']
new_counterpart_vals.append(self._build_write_off_vals(
statement_line, write_off_vals, balance=wo_balance,
))
remaining = 0
# Replace the bank move line_ids: keep liquidity, drop everything
# else, append new counterparts.
ops = []
for line in (suspense_lines | other_lines):
ops.append(Command.unlink(line.id))
for vals in new_counterpart_vals:
ops.append(Command.create(vals))
editable_move = bank_move.with_context(
force_delete=True, skip_readonly_check=True)
prior_line_ids = set(bank_move.line_ids.ids)
editable_move.write({'line_ids': ops})
new_lines = bank_move.line_ids.filtered(
lambda line: line.id not in prior_line_ids)
# Reconcile each new counterpart with its matched invoice line.
# The first N new lines correspond to the first N against_lines
# (where N may be < len(against_lines) if the bank amount ran out).
# Any trailing new line is a write-off and has no invoice pair.
Partial = self.env['account.partial.reconcile']
new_partial_ids = []
invoice_counterparts = new_lines[:min(len(new_lines),
len(against_lines))]
for new_line, inv_line in zip(invoice_counterparts, against_lines):
pair = new_line | inv_line
existing = set(Partial.search([
'|',
('debit_move_id', 'in', pair.ids),
('credit_move_id', 'in', pair.ids),
]).ids)
pair.reconcile()
added = Partial.search([
'|',
('debit_move_id', 'in', pair.ids),
('credit_move_id', 'in', pair.ids),
]).filtered(lambda p: p.id not in existing)
new_partial_ids.extend(added.ids)
self._post_audit(
statement_line, new_partial_ids, source='engine.reconcile_one')
if against_lines:
self._record_precedent(statement_line, against_lines)
return {
'partial_ids': new_partial_ids,
'exchange_diff_move_id': None,
'write_off_move_id': write_off_move_id,
}
@api.model
def reconcile_batch(self, statement_lines, *, strategy='auto'):
"""Bulk-reconcile a recordset using the chosen strategy.
Returns: ``{'reconciled_count': int, 'skipped': int,
'errors': [...]}``
"""
reconciled = 0
skipped = 0
errors = []
for line in statement_lines:
if line.is_reconciled:
skipped += 1
continue
# Per-line savepoint so a single DB-level failure (e.g. a
# check-constraint violation on one bad line) doesn't poison
# the whole batch's transaction.
try:
with self.env.cr.savepoint():
candidates = self._fetch_candidates(line)
picked = self._apply_strategy(
line, candidates, strategy)
if picked:
self.reconcile_one(line, against_lines=picked)
reconciled += 1
else:
skipped += 1
except Exception as e: # noqa: BLE001
errors.append({'line_id': line.id, 'error': str(e)})
_logger.warning(
"reconcile_batch failed for line %s: %s", line.id, e)
return {
'reconciled_count': reconciled,
'skipped': skipped,
'errors': errors,
}
@api.model
def suggest_matches(self, statement_lines, *, limit_per_line=3):
"""Compute and persist AI suggestions per line.
Returns: dict mapping ``line_id`` -> list of suggestion dicts.
"""
out = {}
Suggestion = self.env['fusion.reconcile.suggestion']
for line in statement_lines:
candidates_records = self._fetch_candidates(line)
if not candidates_records:
continue
candidates_dataclasses = self._records_to_candidates(
line, candidates_records)
scored = score_candidates(
self.env,
statement_line=line,
candidates=candidates_dataclasses,
k=limit_per_line,
use_ai=True,
)
Suggestion.search([
('statement_line_id', '=', line.id),
('state', '=', 'pending'),
]).write({'state': 'superseded'})
line_suggestions = []
for rank, s in enumerate(scored, start=1):
sug = Suggestion.create({
'company_id': line.company_id.id,
'statement_line_id': line.id,
'proposed_move_line_ids': [(6, 0, [s.candidate_id])],
'confidence': s.confidence,
'rank': rank,
'reasoning': s.reasoning,
'score_amount_match': s.score_amount_match,
'score_partner_pattern': s.score_partner_pattern,
'score_precedent_similarity': s.score_precedent_similarity,
'score_ai_rerank': s.score_ai_rerank,
'generated_by': 'on_demand',
'state': 'pending',
})
line_suggestions.append({
'id': sug.id,
'rank': rank,
'confidence': s.confidence,
'reasoning': s.reasoning,
'candidate_id': s.candidate_id,
})
out[line.id] = line_suggestions
return out
@api.model
def accept_suggestion(self, suggestion):
"""User clicked Accept on a suggestion -> reconcile via its proposal.
Returns: same shape as ``reconcile_one``.
"""
if isinstance(suggestion, int):
suggestion = self.env['fusion.reconcile.suggestion'].browse(
suggestion)
suggestion.ensure_one()
line = suggestion.statement_line_id
against = suggestion.proposed_move_line_ids
result = self.reconcile_one(line, against_lines=against)
suggestion.write({
'state': 'accepted',
'accepted_at': fields.Datetime.now(),
'accepted_by': self.env.uid,
})
return result
@api.model
def write_off(self, statement_line, *, account, amount, label, tax_id=None):
"""Create a write-off move + reconcile the bank line against it.
Returns: same shape as ``reconcile_one``.
"""
write_off_vals = {
'account_id': account.id if hasattr(account, 'id') else account,
'amount': amount,
'tax_id': (tax_id.id if (tax_id and hasattr(tax_id, 'id'))
else tax_id),
'label': label,
}
return self.reconcile_one(
statement_line, against_lines=None, write_off_vals=write_off_vals)
@api.model
def unreconcile(self, partial_reconciles):
"""Reverse a reconciliation. Handles full vs. partial chains.
Because ``reconcile_one`` rewrites the bank move's suspense line into
one or more counterpart lines, simply deleting the
``account.partial.reconcile`` rows is not enough — the bank move
would still look reconciled (no suspense line, no residual). We
delegate to V19's standard ``account.bank.statement.line.
action_undo_reconciliation`` for any affected bank line, which
clears the partials AND restores the original suspense state.
Returns: ``{'unreconciled_line_ids': [...]}``
"""
partial_reconciles = partial_reconciles.exists()
if not partial_reconciles:
return {'unreconciled_line_ids': []}
all_lines = (
partial_reconciles.mapped('debit_move_id')
| partial_reconciles.mapped('credit_move_id')
)
line_ids = all_lines.ids
# Find any bank statement lines whose move owns one of these journal
# items; route them through the standard undo flow which both
# deletes the partials and restores the suspense line.
affected_bank_lines = self.env['account.bank.statement.line'].search([
('move_id', 'in', all_lines.mapped('move_id').ids),
])
if affected_bank_lines:
affected_bank_lines.action_undo_reconciliation()
# Anything still hanging around (rare — non-bank-line reconciles)
# gets a direct unlink as a fallback.
remaining = partial_reconciles.exists()
if remaining:
remaining.unlink()
return {'unreconciled_line_ids': line_ids}
# ============================================================
# PRIVATE HELPERS
# ============================================================
def _validate_reconcile(self, statement_line, against_lines):
"""Phase 2: structural + safety checks."""
if not statement_line.exists():
raise ValidationError(_("Statement line does not exist"))
company = statement_line.company_id
line_date = statement_line.date
lock_date = company.fiscalyear_lock_date
if lock_date and line_date and line_date <= lock_date:
raise ValidationError(_(
"Cannot reconcile: line date %(line)s is on or before fiscal "
"year lock date %(lock)s",
line=line_date,
lock=lock_date,
))
def _build_counterpart_vals(self, statement_line, inv_line, *,
allocated_balance):
"""Build the vals for one counterpart line that mirrors an invoice
line on the bank move.
``allocated_balance`` is the signed company-currency balance to write
on the counterpart. It is clamped (by the caller) so that the bank
move stays balanced and no invoice gets over-paid. We scale
``amount_currency`` proportionally for multi-currency lines.
"""
inv_residual = inv_line.amount_residual
if inv_residual:
scale = abs(allocated_balance) / abs(inv_residual)
else:
scale = 1.0
amount_currency = -inv_line.amount_residual_currency * scale
return {
'name': inv_line.name or statement_line.payment_ref or '',
'account_id': inv_line.account_id.id,
'partner_id': (inv_line.partner_id.id
if inv_line.partner_id else False),
'currency_id': inv_line.currency_id.id,
'amount_currency': amount_currency,
'balance': allocated_balance,
}
def _build_write_off_vals(self, statement_line, write_off_vals, *,
balance):
"""Build the vals for a write-off counterpart line on the bank move.
``balance`` is the signed company-currency balance the write-off
line must carry to keep the bank move balanced.
"""
vals = {
'name': write_off_vals.get('label') or _('Write-off'),
'account_id': write_off_vals['account_id'],
'partner_id': (statement_line.partner_id.id
if statement_line.partner_id else False),
'balance': balance,
}
if write_off_vals.get('tax_id'):
vals['tax_ids'] = [(6, 0, [write_off_vals['tax_id']])]
return vals
def _fetch_candidates(self, statement_line):
"""SQL pre-filter: open journal items matching partner + reconcilable
account."""
domain = [
('parent_state', '=', 'posted'),
('account_id.reconcile', '=', True),
('reconciled', '=', False),
('display_type', 'not in', ('line_section', 'line_note')),
]
if statement_line.partner_id:
domain.append(('partner_id', '=', statement_line.partner_id.id))
return self.env['account.move.line'].search(domain, limit=200)
def _records_to_candidates(self, statement_line, records):
"""Convert ``account.move.line`` recordset to ``Candidate`` dataclasses."""
today = fields.Date.today()
result = []
for c in records:
ref_date = c.date_maturity or c.date or today
age_days = (today - ref_date).days
result.append(Candidate(
id=c.id,
amount=abs(c.amount_residual) or abs(c.balance),
partner_id=c.partner_id.id if c.partner_id else 0,
age_days=age_days,
))
return result
def _apply_strategy(self, line, candidate_records, strategy):
"""Apply the named strategy. Returns matching ``account.move.line``
recordset, or empty recordset if nothing matched."""
AML = self.env['account.move.line']
if not candidate_records:
return AML
candidate_dcs = self._records_to_candidates(line, candidate_records)
bank_amount = abs(line.amount)
if strategy == 'auto':
for strat_class in (AmountExactStrategy,
MultiInvoiceStrategy,
FIFOStrategy):
result = strat_class().match(
bank_amount=bank_amount, candidates=candidate_dcs)
if result.picked_ids:
return AML.browse(result.picked_ids)
return AML
def _post_audit(self, statement_line, partial_ids, source):
"""Append an audit log to the bank-line move's chatter."""
if not statement_line.move_id:
return
try:
statement_line.move_id.message_post(
body=_(
"Reconciled via %(source)s; %(count)d partial(s) created: "
"%(ids)s",
source=source,
count=len(partial_ids),
ids=partial_ids,
),
)
except Exception as e: # noqa: BLE001
_logger.debug(
"Audit log skipped for line %s: %s", statement_line.id, e)
def _record_precedent(self, statement_line, against_lines):
"""Append a precedent for future pattern learning. Best-effort."""
if not against_lines:
return
try:
self.env['fusion.reconcile.precedent'].sudo().create({
'company_id': statement_line.company_id.id,
'partner_id': (statement_line.partner_id.id
if statement_line.partner_id else False),
'amount': abs(statement_line.amount),
'currency_id': statement_line.currency_id.id,
'date': statement_line.date,
'memo_tokens': ','.join(
tokenize_memo(statement_line.payment_ref)),
'journal_id': statement_line.journal_id.id,
'matched_move_line_count': len(against_lines),
'matched_account_ids': ','.join(
str(i) for i in against_lines.mapped('account_id').ids),
'reconciler_user_id': self.env.uid,
'reconciled_at': fields.Datetime.now(),
'source': 'manual',
})
except Exception as e: # noqa: BLE001
_logger.warning(
"Failed to record precedent for line %s: %s",
statement_line.id, e)

View File

@@ -0,0 +1,55 @@
"""Per-partner bank reconciliation pattern aggregate.
One row per (company_id, partner_id). Continuously summarises HOW this
partner gets reconciled. Recomputed nightly via cron from the precedent
table. Used as a feature input to confidence_scoring.
"""
from odoo import fields, models
class FusionReconcilePattern(models.Model):
_name = "fusion.reconcile.pattern"
_description = "Per-partner bank reconciliation pattern aggregate"
_rec_name = "partner_id"
company_id = fields.Many2one('res.company', required=True, index=True,
default=lambda self: self.env.company)
partner_id = fields.Many2one('res.partner', required=True, index=True)
# Volume + cadence
reconcile_count = fields.Integer(default=0,
help="Total past reconciles for this partner")
typical_amount_range = fields.Char(
help="e.g. '$1,200 $2,400 (median $1,847.50)'")
typical_cadence_days = fields.Float(
help="Mean inter-reconcile days")
typical_day_of_month = fields.Char(
help="e.g. '1st, 15th'")
# Matching strategy used historically
pref_strategy = fields.Selection([
('exact_amount', 'Exact-amount-first'),
('fifo', 'FIFO oldest-due-first'),
('multi_invoice', 'Multi-invoice consolidation'),
('cherry_pick', 'Cherry-pick specific invoices'),
])
pref_account_id = fields.Many2one('account.account',
help="Most-used target account")
# Memo signature
common_memo_tokens = fields.Char(
help="Comma-separated tokens that appear in ≥30% of past reconciles")
# Tax + write-off habits
common_writeoff_account_id = fields.Many2one('account.account')
common_writeoff_tax_id = fields.Many2one('account.tax')
typical_writeoff_amount = fields.Float(
help="e.g. 0.05 for rounding diffs")
last_refreshed_at = fields.Datetime()
_uniq_company_partner = models.Constraint(
'unique(company_id, partner_id)',
'One pattern row per (company, partner) — already exists.',
)

View File

@@ -0,0 +1,50 @@
"""Per-historical-decision reconciliation memory.
One row per past reconciliation. Holds the full feature vector + outcome,
used by precedent_lookup for K-nearest-neighbour search when scoring a
new bank line.
"""
from odoo import fields, models
class FusionReconcilePrecedent(models.Model):
_name = "fusion.reconcile.precedent"
_description = "Historical bank reconciliation decision (memory)"
_order = "reconciled_at desc, id desc"
company_id = fields.Many2one('res.company', required=True, index=True,
default=lambda self: self.env.company)
partner_id = fields.Many2one('res.partner', index=True)
# Bank line features (the "input")
amount = fields.Monetary(currency_field='currency_id')
currency_id = fields.Many2one('res.currency')
date = fields.Date()
memo_tokens = fields.Char(
help="Comma-separated normalized memo tokens (output of memo_tokenizer)")
journal_id = fields.Many2one('account.journal')
# Outcome (the "decision made")
matched_move_line_count = fields.Integer(
help="1 = exact, 2-3 = consolidation, etc.")
matched_account_ids = fields.Char(
help="Comma-separated account.account IDs that were matched against")
matched_invoice_ages_days = fields.Char(
help="Comma-separated days-old at reconcile time, e.g. '12, 45, 78'")
write_off_amount = fields.Float()
write_off_account_id = fields.Many2one('account.account')
exchange_diff = fields.Boolean()
# Provenance
reconciler_user_id = fields.Many2one('res.users')
reconciled_at = fields.Datetime()
source = fields.Selection([
('historical_bootstrap', 'Imported from history'),
('backfill', 'Backfilled from account.partial.reconcile (migration)'),
('manual', 'Manual reconcile via fusion'),
('ai_accepted', 'AI suggestion accepted'),
('auto_rule', 'account.reconcile.model auto-fired'),
], required=True)
# No uniqueness constraint — multiple reconciles can share features

View File

@@ -0,0 +1,137 @@
"""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)

View File

@@ -0,0 +1,91 @@
"""Materialized view exposing pre-aggregated unreconciled-bank-line data.
The MV is created in the model's init() (called by Odoo on install/upgrade).
Refresh strategy:
- Cron (every 5 min) — see fusion_accounting_bank_rec/data/cron.xml (Task 25)
- Triggered refresh after suggestion writes (handled in fusion_reconcile_suggestion.py)
"""
import logging
import os
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class FusionUnreconciledBankLineMV(models.Model):
_name = "fusion.unreconciled.bank.line.mv"
_description = "Materialized view of unreconciled bank lines for OWL widget"
_auto = False # we manage the table ourselves
_table = "fusion_unreconciled_bank_line_mv"
_order = "date desc, id desc"
# Fields mirror the columns in the SQL view; required so Odoo can read them.
company_id = fields.Many2one('res.company', readonly=True)
journal_id = fields.Many2one('account.journal', readonly=True)
date = fields.Date(readonly=True)
amount = fields.Float(readonly=True)
payment_ref = fields.Char(readonly=True)
currency_id = fields.Many2one('res.currency', readonly=True)
partner_id = fields.Many2one('res.partner', readonly=True)
create_date = fields.Datetime(readonly=True)
top_suggestion_id = fields.Many2one('fusion.reconcile.suggestion', readonly=True)
top_confidence = fields.Float(readonly=True)
confidence_band = fields.Selection([
('high', 'High'),
('medium', 'Medium'),
('low', 'Low'),
('none', 'None'),
], readonly=True)
attachment_count = fields.Integer(readonly=True)
partner_reconcile_count = fields.Integer(readonly=True)
def init(self):
"""Create the MV if missing.
Reads create_mv_unreconciled_bank_line.sql and executes it. Idempotent
because the SQL uses CREATE MATERIALIZED VIEW IF NOT EXISTS."""
sql_path = os.path.join(
os.path.dirname(__file__), '..', 'data', 'sql',
'create_mv_unreconciled_bank_line.sql')
with open(sql_path, 'r') as f:
sql = f.read()
self.env.cr.execute(sql)
_logger.info(
"fusion_unreconciled_bank_line_mv: created/verified MV + indexes")
@api.model
def _refresh(self, *, concurrently=True):
"""Refresh the MV.
If ``concurrently=True`` (default), uses
REFRESH MATERIALIZED VIEW CONCURRENTLY (requires the unique index).
Falls back to a blocking refresh on the first refresh after creation
(when CONCURRENTLY is not yet allowed because the MV has never been
populated).
Flushes the ORM cache first so the materialization sees the latest
committed-to-DB values for fields like ``is_reconciled`` (computed,
stored — sometimes still buffered in the cache mid-request)."""
self.env.flush_all()
keyword = "CONCURRENTLY" if concurrently else ""
try:
self.env.cr.execute(
f"REFRESH MATERIALIZED VIEW {keyword} fusion_unreconciled_bank_line_mv"
)
_logger.debug(
"fusion_unreconciled_bank_line_mv refreshed (%s)",
'concurrent' if concurrently else 'blocking')
except Exception as e: # noqa: BLE001
# CONCURRENTLY fails on first refresh after creation if the MV is
# empty / has never been populated; fall back to non-concurrent.
if concurrently:
_logger.warning(
"Concurrent MV refresh failed (%s); falling back to "
"blocking refresh", e)
self.env.cr.execute(
"REFRESH MATERIALIZED VIEW fusion_unreconciled_bank_line_mv"
)
else:
raise