# Fusion Accounting - Bank Statement & Statement Line Extensions # Reconciliation widget support, auto-reconciliation CRON, partner matching import logging from dateutil.relativedelta import relativedelta from itertools import product from markupsafe import Markup from odoo import _, api, fields, models from odoo.addons.base.models.res_bank import sanitize_account_number from odoo.exceptions import UserError from odoo.osv import expression from odoo.tools import html2plaintext _log = logging.getLogger(__name__) class FusionBankStatement(models.Model): """Extends bank statements with reconciliation widget integration and PDF attachment generation.""" _name = "account.bank.statement" _inherit = ['mail.thread.main.attachment', 'account.bank.statement'] # ---- Actions ---- def action_open_bank_reconcile_widget(self): """Launch the bank reconciliation widget scoped to this statement.""" self.ensure_one() return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget( name=self.name, default_context={ 'search_default_statement_id': self.id, 'search_default_journal_id': self.journal_id.id, }, extra_domain=[('statement_id', '=', self.id)], ) def action_generate_attachment(self): """Render statement as PDF and attach it to the record.""" report_sudo = self.env['ir.actions.report'].sudo() stmt_report_action = self.env.ref('account.action_report_account_statement') for stmt in self: stmt_report = stmt_report_action.sudo() pdf_bytes, _mime = report_sudo._render_qweb_pdf( stmt_report, res_ids=stmt.ids, ) filename = ( _("Bank Statement %s.pdf", stmt.name) if stmt.name else _("Bank Statement.pdf") ) stmt.attachment_ids |= self.env['ir.attachment'].create({ 'name': filename, 'type': 'binary', 'mimetype': 'application/pdf', 'raw': pdf_bytes, 'res_model': stmt._name, 'res_id': stmt.id, }) return stmt_report_action.report_action(docids=self) class FusionBankStatementLine(models.Model): """Extends bank statement lines with reconciliation workflow, automated matching via CRON, and partner detection heuristics.""" _inherit = 'account.bank.statement.line' # ---- Fields ---- cron_last_check = fields.Datetime() # Ensure each imported transaction is unique unique_import_id = fields.Char( string='Import ID', readonly=True, copy=False, ) _sql_constraints = [ ( 'unique_import_id', 'unique (unique_import_id)', 'A bank account transaction can be imported only once!', ), ] # ---- Quick Actions ---- def action_save_close(self): """Close the current form after saving.""" return {'type': 'ir.actions.act_window_close'} def action_save_new(self): """Save and immediately open a fresh statement line form.""" window_action = self.env['ir.actions.act_window']._for_xml_id( 'fusion_accounting.action_bank_statement_line_form_bank_rec_widget' ) window_action['context'] = { 'default_journal_id': self.env.context['default_journal_id'], } return window_action # ---- Reconciliation Widget ---- @api.model def _action_open_bank_reconciliation_widget( self, extra_domain=None, default_context=None, name=None, kanban_first=True, ): """Return an action dict that opens the bank reconciliation widget.""" xml_suffix = '_kanban' if kanban_first else '' act_ref = f'fusion_accounting.action_bank_statement_line_transactions{xml_suffix}' widget_action = self.env['ir.actions.act_window']._for_xml_id(act_ref) widget_action.update({ 'name': name or _("Bank Reconciliation"), 'context': default_context or {}, 'domain': [('state', '!=', 'cancel')] + (extra_domain or []), }) # Provide a helpful empty-state message listing supported import formats available_fmts = self.env['account.journal']._get_bank_statements_available_import_formats() widget_action['help'] = Markup( "

{heading}

" "

{detail}
{hint}

" ).format( heading=_('Nothing to do here!'), detail=_('No transactions matching your filters were found.'), hint=_('Click "New" or upload a %s.', ", ".join(available_fmts)), ) return widget_action def action_open_recon_st_line(self): """Open the reconciliation widget focused on a single statement line.""" self.ensure_one() return self.env['account.bank.statement.line']._action_open_bank_reconciliation_widget( name=self.name, default_context={ 'default_statement_id': self.statement_id.id, 'default_journal_id': self.journal_id.id, 'default_st_line_id': self.id, 'search_default_id': self.id, }, ) # ---- Auto-Reconciliation CRON ---- def _cron_try_auto_reconcile_statement_lines(self, batch_size=None, limit_time=0): """Attempt to automatically reconcile statement lines using configured reconciliation models. Processes unreconciled lines prioritised by those never previously checked by the CRON.""" def _fetch_candidates(eligible_companies): """Return a batch of unreconciled lines and a marker for the next batch.""" leftover_id = None fetch_limit = (batch_size + 1) if batch_size else None search_domain = [ ('is_reconciled', '=', False), ('create_date', '>', run_start.date() - relativedelta(months=3)), ('company_id', 'in', eligible_companies.ids), ] candidates = self.search( search_domain, limit=fetch_limit, order="cron_last_check ASC NULLS FIRST, id", ) if batch_size and len(candidates) > batch_size: leftover_id = candidates[batch_size].id candidates = candidates[:batch_size] return candidates, leftover_id run_start = fields.Datetime.now() # Identify companies that have auto-reconcile models configured recon_companies = child_cos = ( self.env['account.reconcile.model'] .search_fetch( [ ('auto_reconcile', '=', True), ('rule_type', 'in', ('writeoff_suggestion', 'invoice_matching')), ], ['company_id'], ) .company_id ) if not recon_companies: return while child_cos := child_cos.child_ids: recon_companies += child_cos target_lines, next_line_id = ( (self, None) if self else _fetch_candidates(recon_companies) ) auto_matched_count = 0 for idx, st_line in enumerate(target_lines): if limit_time and (fields.Datetime.now().timestamp() - run_start.timestamp()) > limit_time: next_line_id = st_line.id target_lines = target_lines[:idx] break rec_widget = self.env['bank.rec.widget'].with_context( default_st_line_id=st_line.id, ).new({}) rec_widget._action_trigger_matching_rules() if rec_widget.state == 'valid' and rec_widget.matching_rules_allow_auto_reconcile: try: rec_widget._action_validate() if st_line.is_reconciled: model_names = ', '.join( st_line.move_id.line_ids.reconcile_model_id.mapped('name') ) st_line.move_id.message_post( body=_( "This transaction was auto-reconciled using model '%s'.", model_names, ), ) auto_matched_count += 1 except UserError as exc: _log.info( "Auto-reconciliation of statement line %s failed: %s", st_line.id, str(exc), ) continue target_lines.write({'cron_last_check': run_start}) if next_line_id: pending_line = self.env['account.bank.statement.line'].browse(next_line_id) if auto_matched_count or not pending_line.cron_last_check: self.env.ref( 'fusion_accounting.auto_reconcile_bank_statement_line' )._trigger() # ---- Partner Detection ---- def _retrieve_partner(self): """Heuristically determine the partner for this statement line by inspecting bank account numbers, partner names, and reconciliation model mappings.""" self.ensure_one() # 1. Already assigned if self.partner_id: return self.partner_id # 2. Match by bank account number if self.account_number: normalised_number = sanitize_account_number(self.account_number) if normalised_number: bank_domain = [('sanitized_acc_number', 'ilike', normalised_number)] for company_filter in ( [('company_id', 'parent_of', self.company_id.id)], [('company_id', '=', False)], ): matched_banks = self.env['res.partner.bank'].search( company_filter + bank_domain ) if len(matched_banks.partner_id) == 1: return matched_banks.partner_id # Filter out archived partners when multiple matches active_banks = matched_banks.filtered(lambda b: b.partner_id.active) if len(active_banks) == 1: return active_banks.partner_id # 3. Match by partner name if self.partner_name: name_match_strategies = product( [ ('complete_name', '=ilike', self.partner_name), ('complete_name', 'ilike', self.partner_name), ], [ ('company_id', 'parent_of', self.company_id.id), ('company_id', '=', False), ], ) for combined_domain in name_match_strategies: found_partner = self.env['res.partner'].search( list(combined_domain) + [('parent_id', '=', False)], limit=2, ) if len(found_partner) == 1: return found_partner # 4. Match through reconcile model partner mappings applicable_models = self.env['account.reconcile.model'].search([ *self.env['account.reconcile.model']._check_company_domain(self.company_id), ('rule_type', '!=', 'writeoff_button'), ]) for recon_model in applicable_models: mapped_partner = recon_model._get_partner_from_mapping(self) if mapped_partner and recon_model._is_applicable_for(self, mapped_partner): return mapped_partner return self.env['res.partner'] # ---- Text Extraction for Matching ---- def _get_st_line_strings_for_matching(self, allowed_fields=None): """Collect textual values from the statement line for use in matching algorithms.""" self.ensure_one() collected_strings = [] if not allowed_fields or 'payment_ref' in allowed_fields: if self.payment_ref: collected_strings.append(self.payment_ref) if not allowed_fields or 'narration' in allowed_fields: plain_notes = html2plaintext(self.narration or "") if plain_notes: collected_strings.append(plain_notes) if not allowed_fields or 'ref' in allowed_fields: if self.ref: collected_strings.append(self.ref) return collected_strings # ---- Domain Helpers ---- def _get_default_amls_matching_domain(self): """Exclude stock valuation accounts from the default matching domain.""" base_domain = super()._get_default_amls_matching_domain() stock_categories = self.env['product.category'].search([ '|', ('property_stock_account_input_categ_id', '!=', False), ('property_stock_account_output_categ_id', '!=', False), ]) excluded_accounts = ( stock_categories.mapped('property_stock_account_input_categ_id') + stock_categories.mapped('property_stock_account_output_categ_id') ) if excluded_accounts: return expression.AND([ base_domain, [('account_id', 'not in', tuple(set(excluded_accounts.ids)))], ]) return base_domain