feat(fusion_accounting_bank_rec): migration wizard bootstrap step

Adds bank_rec_bootstrap step that backfills fusion.reconcile.precedent
from existing account.partial.reconcile rows during migration. This
gives the AI memory from past Enterprise reconciles. Also triggers
pattern refresh + MV refresh for immediate UI readiness.

- New service services/precedent_backfill.py walks
  account.partial.reconcile rows, identifies the bank-statement-line
  side, and creates a precedent per qualifying partial. Idempotent via
  (statement_line, account, amount, source='backfill') signature.
- New model models/fusion_migration_wizard.py inherits
  fusion.migration.wizard, exposes _bank_rec_bootstrap_step() (callable
  from tests/audit), and overrides action_run_migration() to call
  super() + the bootstrap.
- Adds 'backfill' to fusion.reconcile.precedent.source selection.
- Adds fusion_accounting_migration to depends.

Made-with: Cursor
This commit is contained in:
gsinghpal
2026-04-19 13:24:17 -04:00
parent 25f033d0c8
commit b6aedc9bbe
6 changed files with 207 additions and 2 deletions

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting — Bank Reconciliation',
'version': '19.0.1.0.20',
'version': '19.0.1.0.21',
'category': 'Accounting/Accounting',
'sequence': 28,
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',
@@ -24,7 +24,7 @@ Built by Nexa Systems Inc.
'author': 'Nexa Systems Inc.',
'website': 'https://nexasystems.ca',
'maintainer': 'Nexa Systems Inc.',
'depends': ['fusion_accounting_core'],
'depends': ['fusion_accounting_core', 'fusion_accounting_migration'],
'external_dependencies': {
'python': ['hypothesis'],
},

View File

@@ -7,3 +7,4 @@ 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,97 @@
"""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.
"""
_ = 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

@@ -41,6 +41,7 @@ class FusionReconcilePrecedent(models.Model):
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'),

View File

@@ -4,3 +4,4 @@ from . import matching_strategies
from . import precedent_lookup
from . import pattern_extractor
from . import confidence_scoring
from . import precedent_backfill

View File

@@ -0,0 +1,105 @@
"""Pure-Python helpers for backfilling fusion.reconcile.precedent
from existing account.partial.reconcile rows during migration.
Strategy:
- Each account.partial.reconcile that involves at least one
account.bank.statement.line's reconcile-account line is a candidate.
- One precedent per qualifying partial. The (statement_line.id, account_id,
amount) triple is encoded into matched_account_ids so a second run can
detect and skip already-backfilled rows (idempotency).
"""
import logging
from .memo_tokenizer import tokenize_memo
_logger = logging.getLogger(__name__)
def _identify_bank_side(partial):
"""Return (bank_move_line, counterpart_move_line, statement_line_id)
or (None, None, None) if neither side is a bank statement line."""
debit_line = partial.debit_move_id
credit_line = partial.credit_move_id
if debit_line.move_id.statement_line_id:
return debit_line, credit_line, debit_line.move_id.statement_line_id.id
if credit_line.move_id.statement_line_id:
return credit_line, debit_line, credit_line.move_id.statement_line_id.id
return None, None, None
def backfill_precedents(env, *, company_id=None, batch_size=500, limit=10000):
"""Walk account.partial.reconcile and create fusion.reconcile.precedent
rows for any reconcile that involves a bank statement line.
Idempotent: skips partials whose (statement_line, account, amount)
signature is already present in fusion.reconcile.precedent (encoded
via matched_account_ids).
Returns dict with `created` and `skipped` counts.
"""
Precedent = env['fusion.reconcile.precedent'].sudo()
Partial = env['account.partial.reconcile'].sudo()
Line = env['account.bank.statement.line'].sudo()
domain = []
if company_id:
domain.append(('company_id', '=', company_id))
partials = Partial.search(domain, limit=limit, order='id asc')
created = 0
skipped = 0
for partial in partials:
bank_line, counterpart, bsl_id = _identify_bank_side(partial)
if not bsl_id:
skipped += 1
continue
signature_account = str(counterpart.account_id.id)
existing = Precedent.search([
('partner_id', '=',
counterpart.partner_id.id if counterpart.partner_id else False),
('amount', '=', abs(partial.amount)),
('matched_account_ids', '=ilike', f'%{signature_account}%'),
('source', '=', 'backfill'),
], limit=1)
if existing:
skipped += 1
continue
statement_line = Line.browse(bsl_id)
try:
currency = (partial.debit_currency_id
or partial.company_id.currency_id)
Precedent.create({
'company_id': partial.company_id.id,
'partner_id': (counterpart.partner_id.id
if counterpart.partner_id else False),
'amount': abs(partial.amount),
'currency_id': currency.id,
'date': statement_line.date or partial.create_date.date(),
'memo_tokens': ','.join(
tokenize_memo(statement_line.payment_ref or '')),
'journal_id': statement_line.journal_id.id,
'matched_move_line_count': 1,
'matched_account_ids': signature_account,
'reconciler_user_id': partial.create_uid.id,
'reconciled_at': partial.create_date,
'source': 'backfill',
})
created += 1
if created % batch_size == 0:
env.cr.commit()
_logger.info(
"Backfill progress: %d created, %d skipped",
created, skipped)
except Exception as e: # noqa: BLE001
_logger.warning("Backfill skip partial %s: %s", partial.id, e)
skipped += 1
_logger.info(
"precedent_backfill complete: %d created, %d skipped",
created, skipped)
return {'created': created, 'skipped': skipped}