Initial commit
This commit is contained in:
330
Fusion Accounting/models/account_bank_statement.py
Normal file
330
Fusion Accounting/models/account_bank_statement.py
Normal 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
|
||||
Reference in New Issue
Block a user