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:
@@ -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'],
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
97
fusion_accounting_bank_rec/models/fusion_migration_wizard.py
Normal file
97
fusion_accounting_bank_rec/models/fusion_migration_wizard.py
Normal 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,
|
||||
},
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -4,3 +4,4 @@ from . import matching_strategies
|
||||
from . import precedent_lookup
|
||||
from . import pattern_extractor
|
||||
from . import confidence_scoring
|
||||
from . import precedent_backfill
|
||||
|
||||
105
fusion_accounting_bank_rec/services/precedent_backfill.py
Normal file
105
fusion_accounting_bank_rec/services/precedent_backfill.py
Normal 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}
|
||||
Reference in New Issue
Block a user