Initial commit

This commit is contained in:
gsinghpal
2026-02-22 01:22:18 -05:00
commit 5200d5baf0
2394 changed files with 386834 additions and 0 deletions

View File

@@ -0,0 +1,330 @@
# 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(
"<p class='o_view_nocontent_smiling_face'>{heading}</p>"
"<p>{detail}<br/>{hint}</p>"
).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