From b6aedc9bbece5e5c5b6ac7e3796a2aa68e393be4 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 13:24:17 -0400 Subject: [PATCH] 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 --- fusion_accounting_bank_rec/__manifest__.py | 4 +- fusion_accounting_bank_rec/models/__init__.py | 1 + .../models/fusion_migration_wizard.py | 97 ++++++++++++++++ .../models/fusion_reconcile_precedent.py | 1 + .../services/__init__.py | 1 + .../services/precedent_backfill.py | 105 ++++++++++++++++++ 6 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 fusion_accounting_bank_rec/models/fusion_migration_wizard.py create mode 100644 fusion_accounting_bank_rec/services/precedent_backfill.py diff --git a/fusion_accounting_bank_rec/__manifest__.py b/fusion_accounting_bank_rec/__manifest__.py index c4d62e60..60cb2be9 100644 --- a/fusion_accounting_bank_rec/__manifest__.py +++ b/fusion_accounting_bank_rec/__manifest__.py @@ -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'], }, diff --git a/fusion_accounting_bank_rec/models/__init__.py b/fusion_accounting_bank_rec/models/__init__.py index f293b5fd..d1964a92 100644 --- a/fusion_accounting_bank_rec/models/__init__.py +++ b/fusion_accounting_bank_rec/models/__init__.py @@ -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 diff --git a/fusion_accounting_bank_rec/models/fusion_migration_wizard.py b/fusion_accounting_bank_rec/models/fusion_migration_wizard.py new file mode 100644 index 00000000..1e16c7d5 --- /dev/null +++ b/fusion_accounting_bank_rec/models/fusion_migration_wizard.py @@ -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, + }, + } diff --git a/fusion_accounting_bank_rec/models/fusion_reconcile_precedent.py b/fusion_accounting_bank_rec/models/fusion_reconcile_precedent.py index b7f25671..336caa56 100644 --- a/fusion_accounting_bank_rec/models/fusion_reconcile_precedent.py +++ b/fusion_accounting_bank_rec/models/fusion_reconcile_precedent.py @@ -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'), diff --git a/fusion_accounting_bank_rec/services/__init__.py b/fusion_accounting_bank_rec/services/__init__.py index d91e7e2a..322234d0 100644 --- a/fusion_accounting_bank_rec/services/__init__.py +++ b/fusion_accounting_bank_rec/services/__init__.py @@ -4,3 +4,4 @@ from . import matching_strategies from . import precedent_lookup from . import pattern_extractor from . import confidence_scoring +from . import precedent_backfill diff --git a/fusion_accounting_bank_rec/services/precedent_backfill.py b/fusion_accounting_bank_rec/services/precedent_backfill.py new file mode 100644 index 00000000..32b50c33 --- /dev/null +++ b/fusion_accounting_bank_rec/services/precedent_backfill.py @@ -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}