2023 lines
111 KiB
Python
2023 lines
111 KiB
Python
import logging
|
|
import re
|
|
import string
|
|
|
|
from collections import defaultdict
|
|
from dateutil.relativedelta import relativedelta
|
|
from markupsafe import Markup
|
|
|
|
from odoo import Command, SUPERUSER_ID, _, api, fields, models, modules, tools
|
|
from odoo.exceptions import UserError, ValidationError
|
|
from odoo.fields import Domain
|
|
from odoo.tools import SQL, float_compare, float_is_zero, format_date, frozendict
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
AUTO_STATEMENT_PROCESSING_NUMBER = 80
|
|
AUTO_STATEMENT_PROCESSING_BATCH_SIZE = 100
|
|
|
|
|
|
class AccountBankStatement(models.Model):
|
|
_name = 'account.bank.statement'
|
|
_inherit = ['mail.thread.main.attachment', 'account.bank.statement']
|
|
|
|
def action_open_bank_reconcile_widget(self):
|
|
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_open_journal_invalid_statements(self):
|
|
self.ensure_one()
|
|
return {
|
|
'name': _('Invalid Bank Statements'),
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'account.bank.statement',
|
|
'view_mode': 'list',
|
|
'context': {
|
|
'search_default_journal_id': self.journal_id.id,
|
|
'search_default_invalid': True,
|
|
},
|
|
}
|
|
|
|
def action_generate_attachment(self):
|
|
ir_actions_report_sudo = self.env['ir.actions.report'].sudo()
|
|
statement_report_action = self.env.ref('account.action_report_account_statement')
|
|
for statement in self:
|
|
statement_report = statement_report_action.sudo()
|
|
content, _content_type = ir_actions_report_sudo._render_qweb_pdf(statement_report, res_ids=statement.ids)
|
|
statement.attachment_ids |= self.env['ir.attachment'].create({
|
|
'name': _("Bank Statement %s.pdf", statement.name) if statement.name else _("Bank Statement.pdf"),
|
|
'type': 'binary',
|
|
'mimetype': 'application/pdf',
|
|
'raw': content,
|
|
'res_model': statement._name,
|
|
'res_id': statement.id,
|
|
})
|
|
return statement_report_action.report_action(docids=self)
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
statements = super().create(vals_list)
|
|
if not self.env.context.get('skip_pdf_attachment_generation'):
|
|
statements.filtered(lambda statement: statement.is_complete and (
|
|
not statement.attachment_ids
|
|
or not any(attachment.mimetype == 'application/pdf' for attachment in statement.attachment_ids)
|
|
)).action_generate_attachment()
|
|
|
|
return statements
|
|
|
|
|
|
class AccountBankStatementLine(models.Model):
|
|
_name = 'account.bank.statement.line'
|
|
_inherit = ['account.bank.statement.line', 'mail.thread.main.attachment']
|
|
|
|
# Technical field holding the date of the last time the cron tried to auto-reconcile the statement line. Used to
|
|
# optimize the bank matching process"
|
|
cron_last_check = fields.Datetime()
|
|
|
|
bank_statement_attachment_ids = fields.One2many('ir.attachment', compute='_compute_bank_statement_attachment_ids')
|
|
attachment_ids = fields.One2many('ir.attachment', related="move_id.attachment_ids")
|
|
has_attachments = fields.Boolean(compute='_compute_has_attachments')
|
|
has_invalid_analytics = fields.Boolean(compute='_compute_has_invalid_analytics')
|
|
reconciled_lines_name = fields.Char(compute='_compute_reconciled_lines_name')
|
|
|
|
def action_save_close(self):
|
|
return {'type': 'ir.actions.act_window_close'}
|
|
|
|
def action_save_new(self):
|
|
action = self.env['ir.actions.act_window']._for_xml_id('account_accountant.action_bank_statement_line_form_bank_rec_widget')
|
|
action['context'] = {'default_journal_id': self.env.context['default_journal_id']}
|
|
return action
|
|
|
|
def action_button_draft(self):
|
|
""" This function will allow to reset to draft the move linked to the statement line"""
|
|
self.move_id.button_draft()
|
|
|
|
####################################################
|
|
# COMPUTE METHODS
|
|
####################################################
|
|
|
|
def _compute_bank_statement_attachment_ids(self):
|
|
attachments = self.env['ir.attachment'].search([
|
|
('res_model', '=', 'account.bank.statement'),
|
|
('res_id', 'in', self.statement_id.ids),
|
|
('res_field', 'in', (False, 'invoice_pdf_report_file')),
|
|
]).grouped('res_id')
|
|
|
|
for st_line in self:
|
|
st_line.bank_statement_attachment_ids = attachments.get(st_line.statement_id.id)
|
|
|
|
@api.depends('attachment_ids')
|
|
def _compute_has_attachments(self):
|
|
for st_line in self:
|
|
st_line.has_attachments = (
|
|
st_line.attachment_ids
|
|
or st_line.bank_statement_attachment_ids
|
|
or st_line.line_ids.reconciled_lines_ids.move_attachment_ids
|
|
)
|
|
|
|
@api.depends('line_ids.name')
|
|
def _compute_reconciled_lines_name(self):
|
|
def compute_line_name(aml):
|
|
return (
|
|
aml.first_reconciled_lines_excluding_exchange_diff_id.move_name
|
|
or aml.account_id.display_name
|
|
)
|
|
|
|
for st_line in self:
|
|
reconciled_line_ids = st_line.line_ids.filtered(
|
|
lambda aml: aml.account_id not in {st_line.journal_id.suspense_account_id, st_line.journal_id.default_account_id}
|
|
)[0:5]
|
|
st_line.reconciled_lines_name = ', '.join(reconciled_line_ids.mapped(compute_line_name))
|
|
|
|
@api.depends('line_ids.has_invalid_analytics')
|
|
def _compute_has_invalid_analytics(self):
|
|
for st_line in self:
|
|
st_line.has_invalid_analytics = any(line.has_invalid_analytics for line in st_line.line_ids)
|
|
|
|
####################################################
|
|
# RECONCILIATION PROCESS
|
|
####################################################
|
|
|
|
@api.model
|
|
def _action_open_bank_reconciliation_widget(self, extra_domain=None, default_context=None, name=None, kanban_first=True):
|
|
if default_context is None:
|
|
default_context = {}
|
|
action_reference = 'account_accountant.action_bank_statement_line_transactions' + ('_kanban' if kanban_first else '')
|
|
action = self.env['ir.actions.act_window']._for_xml_id(action_reference)
|
|
|
|
default_journal = self.env['account.journal'].browse(
|
|
default_context.get('default_journal_id', default_context.get('search_default_journal_id'))
|
|
)
|
|
|
|
action.update({
|
|
'name': name or _("Bank Matching"),
|
|
'context': {
|
|
**default_context,
|
|
'bank_statements_source': default_journal.exists().bank_statements_source,
|
|
'auto_statement_processing': True,
|
|
},
|
|
'domain': [('state', '!=', 'cancel')] + (extra_domain or []),
|
|
})
|
|
|
|
return action
|
|
|
|
def action_open_recon_st_line(self):
|
|
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,
|
|
},
|
|
)
|
|
|
|
def _cron_try_auto_reconcile_statement_lines(self, batch_size=None, limit_time=0, company_id=None):
|
|
""" Method called by the CRON to reconcile the statement lines automatically.
|
|
|
|
:param batch_size: The maximum number of statement lines that could be processed at once by the CRON to avoid
|
|
a timeout. If specified, the CRON will be trigger again asap using a CRON trigger in case
|
|
there are still some statement lines to process.
|
|
:param limit_time: Maximum time allowed to run in seconds. 0 if the Cron is allowed to run without time limit.
|
|
:param company_id: Limits the processing to statement lines related to a single company.
|
|
"""
|
|
if limit_time <= 0:
|
|
# 'limit_time_real_cron' defaults to -1.
|
|
# Manual fallback applied for non-POSIX systems where this key is disabled (set to None).
|
|
cron_limit_time = tools.config['limit_time_real_cron'] or -1
|
|
limit_time = cron_limit_time if 0 < cron_limit_time < 180 else 180
|
|
|
|
if limit_time and not batch_size:
|
|
_logger.warning("_cron_try_auto_reconcile_statement_lines called with "
|
|
"limit_time=%r but batch_size=%r won't limit anything", limit_time, batch_size)
|
|
|
|
FAILED_ONCE = '1970-01-01 00:00:00' # special value flaging the line for a retry
|
|
already_processed_during_this_round = [] # ensure we retry in another trigger
|
|
|
|
def compute_st_lines_to_reconcile(company_id=None):
|
|
# Find the bank statement lines that are not reconciled and try to reconcile them automatically.
|
|
# The ones that are never be processed by the CRON before are processed first.
|
|
remaining_line_id = None
|
|
limit = batch_size + 1 if batch_size else None
|
|
# Fetch the lines that haven't been tried before or have only failed once
|
|
domain = Domain([
|
|
('is_reconciled', '=', False),
|
|
('cron_last_check', 'in', [False, FAILED_ONCE]),
|
|
('id', 'not in', already_processed_during_this_round)
|
|
])
|
|
if company_id is not None:
|
|
domain &= Domain(self.env['account.reconcile.model']._check_company_domain(company_id))
|
|
st_lines = self.search(domain, limit=limit, order="cron_last_check ASC NULLS FIRST, id")
|
|
_logger.info("_cron_try_auto_reconcile_statement_lines found %s statement lines", len(st_lines))
|
|
if batch_size and len(st_lines) > batch_size:
|
|
remaining_line_id = st_lines[batch_size].id
|
|
st_lines = st_lines[:batch_size]
|
|
return st_lines, remaining_line_id
|
|
|
|
def is_limit_time_exceeded():
|
|
if batch_size and limit_time:
|
|
# cron limitations only make sense if we have both a batch_size and a limit_time
|
|
return fields.Datetime.now().timestamp() - start_time.timestamp() > limit_time
|
|
# the cron won't be limited, and we'll process all the statement lines never processed before
|
|
return False
|
|
|
|
can_commit = not modules.module.current_test and not self.env.context.get('import_file', False)
|
|
remaining_line_id = None
|
|
|
|
start_time = fields.Datetime.now()
|
|
while not is_limit_time_exceeded():
|
|
try:
|
|
# compute the statement lines to reconcile in this batch size
|
|
st_lines, remaining_line_id = compute_st_lines_to_reconcile(company_id=company_id)
|
|
|
|
if not st_lines:
|
|
break
|
|
|
|
st_lines._try_auto_reconcile_statement_lines(company_id=company_id)
|
|
except Exception as e: # noqa: BLE001
|
|
_logger.warning("Error while processing statement lines: %s", e)
|
|
if not isinstance(e, UserError) and can_commit:
|
|
_logger.warning("_cron_try_auto_reconcile_statement_lines will rollback the cursor")
|
|
self.env.cr.rollback()
|
|
if st_lines.exists():
|
|
second_fail_lines = st_lines.filtered(lambda l: l.cron_last_check)
|
|
second_fail_lines.cron_last_check = fields.Datetime.now()
|
|
|
|
first_fail_lines = st_lines.filtered(lambda l: not l.cron_last_check)
|
|
if first_fail_lines:
|
|
# set the remaining line id so that the cron is re-triggered if there are failed lines to be retried
|
|
remaining_line_id = remaining_line_id or first_fail_lines[0].id
|
|
first_fail_lines.cron_last_check = FAILED_ONCE
|
|
already_processed_during_this_round.extend(first_fail_lines.ids)
|
|
|
|
# Commit if we can, in case an issue arises later.
|
|
if can_commit:
|
|
self.env.cr.commit()
|
|
|
|
if remaining_line_id := remaining_line_id or (already_processed_during_this_round and already_processed_during_this_round[0]):
|
|
_logger.info("_cron_try_auto_reconcile_statement_lines remaining line found (%s), the cron will be triggered again", remaining_line_id)
|
|
# If some statement lines couldn't be processed because of the cron limits, manually re-trigger the cron
|
|
self.env.ref('account_accountant.auto_reconcile_bank_statement_line')._trigger()
|
|
|
|
def _invoice_matching_post_process(self, st_line, amls):
|
|
# no valid candidates found yet, based on payment_reference, try to match on the following criteria:
|
|
# 1) there is a single invoice with the residual amount matching, for the partner
|
|
# 2) the amount correspond to the discounted amount and the payment date is prior to the discount date
|
|
# 3) there is a small difference, in the allowed error margin (3%)
|
|
candidate_amls = self.env['account.move.line']
|
|
# TODO now that the complex regex stuff is gone, try to remove totally the post process and benchmark when it is done in the SQL query
|
|
tolerance = self._get_payment_tolerance()
|
|
for aml in amls:
|
|
if (aml.company_currency_id == st_line.currency_id and (
|
|
aml.amount_residual == st_line.amount
|
|
or (aml.discount_balance == st_line.amount and st_line.date <= aml.discount_date)
|
|
or (aml.amount_residual * (1 - tolerance) <= st_line.amount <= aml.amount_residual * (1 + tolerance))
|
|
)
|
|
) or (
|
|
aml.currency_id == st_line.currency_id and (
|
|
aml.amount_residual_currency == st_line.amount
|
|
or (aml.discount_amount_currency == st_line.amount and st_line.date <= aml.discount_date)
|
|
or (aml.amount_residual_currency * (1 - tolerance) <= st_line.amount <= aml.amount_residual_currency * (1 + tolerance))
|
|
)
|
|
) or (
|
|
aml.currency_id == st_line.foreign_currency_id and (
|
|
aml.amount_residual_currency == st_line.amount_currency
|
|
or (aml.discount_amount_currency == st_line.amount_currency and st_line.date <= aml.discount_date)
|
|
or (aml.amount_residual_currency * (1 - tolerance) <= st_line.amount_currency <= aml.amount_residual_currency * (1 + tolerance))
|
|
)
|
|
):
|
|
candidate_amls += aml
|
|
|
|
# if we have a unique match, we reconcile that move line
|
|
if len(candidate_amls) == 1:
|
|
return candidate_amls
|
|
|
|
# if we have multiple candidates and only one of them has a prior or equal date, we return that one, else we return None
|
|
prior_amls = candidate_amls.filtered(lambda aml: aml.invoice_date and aml.invoice_date <= st_line.date)
|
|
if len(prior_amls) == 1:
|
|
return prior_amls
|
|
return None
|
|
|
|
def _handle_reconciliation_matching_amount(self, st_move_ids, account_ids, remaining_st_line_ids, match_journal=False):
|
|
""" Attempts to automatically reconcile statement lines by matching the total residual amount
|
|
of open move lines for a specific partner and account.
|
|
In case of outstanding account, make sure we don't match an outstanding payment made in
|
|
journal A with a transaction in journal B.
|
|
|
|
:param st_move_ids: set of statement line move ids to exclude from the search
|
|
:param account_ids: set of account ids to match the amls
|
|
:param remaining_st_line_ids: set of remaining statement line ids to reconcile.
|
|
:param match_journal: If True, restricts the search to journal items belonging
|
|
to the same journal as the statement line.
|
|
:return: set of statement line ids that were successfully matched during this pass.
|
|
"""
|
|
processed_st_line_ids = set()
|
|
journal_clause = SQL("AND aml.journal_id = st_line.journal_id") if match_journal else SQL("")
|
|
query = SQL("""
|
|
SELECT st_line.id AS st_line_id,
|
|
ARRAY_AGG(aml.id ORDER BY aml.id ASC) AS all_aml_ids,
|
|
SUM(aml.amount_residual) AS total_residual
|
|
FROM account_bank_statement_line st_line
|
|
JOIN account_move_line aml
|
|
ON st_line.partner_id = aml.partner_id
|
|
AND aml.company_id = st_line.company_id
|
|
AND SIGN(st_line.amount) = SIGN(aml.balance)
|
|
%s
|
|
JOIN account_move move ON aml.move_id = move.id
|
|
WHERE st_line.partner_id IS NOT NULL
|
|
AND aml.move_id NOT IN %s
|
|
AND aml.reconciled IS NOT TRUE
|
|
AND aml.account_id IN %s
|
|
AND (aml.parent_state IN ('draft', 'posted'))
|
|
AND st_line.id IN %s
|
|
GROUP BY st_line.id
|
|
""", journal_clause, tuple(st_move_ids), tuple(account_ids), tuple(remaining_st_line_ids))
|
|
self.env.cr.execute(query)
|
|
|
|
# process then remove matched statement lines
|
|
for st_line_id, all_aml_ids, total_residual in self.env.cr.fetchall():
|
|
st_line = self.browse(st_line_id).with_prefetch(self._prefetch_ids)
|
|
if float_compare(total_residual, st_line.amount, precision_rounding=st_line.currency_id.rounding) == 0:
|
|
# the total open amount for the partner equals the paid amount
|
|
st_line.set_line_bank_statement_line(all_aml_ids)
|
|
_logger.info("try_auto_reconcile - match amount - st_line: %s set lines %s", st_line.id, all_aml_ids)
|
|
elif all_aml_ids:
|
|
amls = self.env['account.move.line'].browse(all_aml_ids)
|
|
candidate_amls = self._invoice_matching_post_process(st_line, amls)
|
|
if candidate_amls:
|
|
st_line.set_line_bank_statement_line(candidate_amls.ids)
|
|
_logger.info("try_auto_reconcile - _invoice_matching_post_process - st_line: %s set lines %s", st_line.id, candidate_amls.ids)
|
|
if st_line.currency_id.is_zero(st_line.amount_residual):
|
|
processed_st_line_ids.add(st_line.id)
|
|
return processed_st_line_ids
|
|
|
|
def _try_auto_reconcile_statement_lines(self, company_id=None):
|
|
st_move_ids = self.mapped('move_id').ids
|
|
# The field `cron_last_check` will be written on all processed lines that requires them to be protected against
|
|
# concurrent update to avoid the whole transaction to be rolled back.
|
|
self.lock_for_update()
|
|
|
|
# get all the reco models to consider (partner mapping and buttons)
|
|
domain = []
|
|
if company_id is not None:
|
|
domain = Domain(self.env['account.reconcile.model']._check_company_domain(company_id))
|
|
reco_models = self.env['account.reconcile.model'].search(domain)
|
|
|
|
##############################################
|
|
# Partner Mapping
|
|
##############################################
|
|
self._partner_mapping(reco_models)
|
|
|
|
##############################################
|
|
# Global Flushing
|
|
##############################################
|
|
# global flushing of tables that should not be updated between the different SQL queries
|
|
self.env['account.account'].flush_model(['account_type', 'active'])
|
|
self.env['account.move'].flush_model(['date', 'amount_total'])
|
|
self.env['account.move.line'].flush_model([
|
|
'ref', 'move_id', 'move_name', 'account_id', 'partner_id', 'company_id',
|
|
'reconciled', 'company_currency_id', 'amount_residual',
|
|
'currency_id', 'amount_residual_currency',
|
|
'discount_date', 'discount_balance', 'discount_amount_currency',
|
|
])
|
|
self.flush_recordset([
|
|
'move_id', 'partner_id', 'company_id', 'currency_id',
|
|
'amount', 'foreign_currency_id', 'amount_currency', 'payment_ref'
|
|
])
|
|
self.env['account.payment'].flush_model(['move_id', 'journal_id', 'memo'])
|
|
|
|
# get all reconciliable accounts that can be used in the bank reconciliation (invoice & payment matching)
|
|
# note that we:
|
|
# * include reconciliable accounts that aren't of receivable/payable type
|
|
# * exclude suspense accounts from bank journals because it wouldn't make sense as we use the suspense accounts to know
|
|
# when an entry has to be processed. If we reconcile it from another way, it would still be considered as unprocessed
|
|
# in the reconciliation widget.
|
|
# * later: exclude the outstanding accounts from bank journals because we don't want an outstanding payment made in journal A
|
|
# to be match with a transaction in journal B. The outstanding payments are treated separatelly prior to the matching
|
|
# * exclude the liquidity account
|
|
account_ids = self.env['account.account'].search([('reconcile', '=', True), ('account_type', 'not in', ('asset_cash', 'liability_credit_card'))])
|
|
account_ids -= self.env['account.journal'].search([('type', 'in', ['bank', 'cash', 'credit'])]).suspense_account_id
|
|
account_ids -= self.journal_id.default_account_id
|
|
account_ids = account_ids.ids
|
|
|
|
##############################################
|
|
# End To End ID
|
|
##############################################
|
|
processed_st_line_ids = self._end_to_end_uuid(st_move_ids, account_ids)
|
|
remaining_st_line_ids = set(self.ids) - processed_st_line_ids
|
|
|
|
# early return if we already processed everything
|
|
if not remaining_st_line_ids:
|
|
self.write({'cron_last_check': self.env.cr.now()})
|
|
return
|
|
|
|
##############################################
|
|
# Outstanding Accounts
|
|
##############################################
|
|
processed_st_line_ids = set()
|
|
outstanding_accounts = self.env['account.payment.method.line'].search([]).payment_account_id
|
|
outstanding_accounts -= self.journal_id.default_account_id
|
|
if outstanding_accounts:
|
|
self._match_accounts_query(st_move_ids, outstanding_accounts.ids, remaining_st_line_ids, True)
|
|
|
|
for st_line_id, aml_id, _aml_amount_residual, _matching_word in self.env.cr.fetchall():
|
|
st_line = self.browse(st_line_id).with_prefetch(self._prefetch_ids) # guarantees batch prefetching if needed
|
|
st_line.with_context(skip_account_review_check=True).set_line_bank_statement_line(aml_id)
|
|
_logger.info("try_auto_reconcile - outstanding - st_line: %s set line %s", st_line.id, aml_id)
|
|
if st_line.currency_id.is_zero(st_line.amount_residual):
|
|
processed_st_line_ids.add(st_line.id)
|
|
|
|
remaining_st_line_ids -= processed_st_line_ids
|
|
if remaining_st_line_ids:
|
|
remaining_st_line_ids -= self._handle_reconciliation_matching_amount(st_move_ids, outstanding_accounts.ids, remaining_st_line_ids, match_journal=True)
|
|
|
|
# Then try to match invoices and payments where we can't be wrong, using the statement lines payment_ref
|
|
# At this point, we're not trying to search for outstanding payments anymore
|
|
account_ids = list(set(account_ids) - set(outstanding_accounts.ids))
|
|
|
|
# early return if we already processed everything
|
|
if not (remaining_st_line_ids and account_ids):
|
|
self.write({'cron_last_check': fields.Datetime.now()})
|
|
return
|
|
|
|
##############################################
|
|
# Remaining Accounts
|
|
##############################################
|
|
self._match_accounts_query(st_move_ids, account_ids, remaining_st_line_ids)
|
|
|
|
st_lines_refs = {}
|
|
to_process = {}
|
|
|
|
# make sure that a match on payment_ref can't be used to match several distinct aml, even if we are sure the same ref can't
|
|
# be found twice because of the HAVING COUNT(*) = 1, we still need to exclude cases where one ref is included in another.
|
|
for st_line_id, aml_id, aml_amount_residual, matching_word in self.env.cr.fetchall():
|
|
st_line = self.browse(st_line_id).with_prefetch(self._prefetch_ids) # guarantees batch prefetching if needed
|
|
# ignore matching words that aren't complete words (not delimited per spaces or punctuation). Doing that in post
|
|
# process rather than in the query itself because the SQL regex operations can't use the index
|
|
if not self._is_properly_surrounded(st_line.payment_ref, matching_word):
|
|
continue
|
|
to_process[st_line_id, matching_word] = [(aml_id, aml_amount_residual)]
|
|
for word in st_lines_refs.get(st_line_id, []):
|
|
if word in matching_word or matching_word in word:
|
|
del to_process[st_line_id, matching_word]
|
|
del to_process[st_line_id, word]
|
|
if st_line_id not in st_lines_refs:
|
|
st_lines_refs[st_line_id] = []
|
|
st_lines_refs[st_line_id].append(matching_word)
|
|
|
|
ref_amls_sum = {}
|
|
for key, to_process_list in to_process.items():
|
|
st_line_id, matching_word = key
|
|
st_line = self.browse(st_line_id).with_prefetch(self._prefetch_ids) # guarantees batch prefetching if needed
|
|
for aml_id, aml_amount_residual in to_process_list:
|
|
# Exclude move lines to prevent reconciliation when the total residual exceeds the statement line amount
|
|
if st_line_id in ref_amls_sum:
|
|
if ref_amls_sum[st_line_id] <= 0:
|
|
continue
|
|
ref_amls_sum[st_line_id] -= aml_amount_residual
|
|
else:
|
|
ref_amls_sum[st_line_id] = st_line.amount - aml_amount_residual
|
|
st_line.with_user(SUPERUSER_ID).set_line_bank_statement_line(aml_id)
|
|
_logger.info("try_auto_reconcile - payment ref - st_line: %s set line %s", st_line.id, aml_id)
|
|
if st_line.currency_id.is_zero(st_line.amount_residual):
|
|
processed_st_line_ids.add(st_line.id)
|
|
remaining_st_line_ids -= processed_st_line_ids
|
|
|
|
# early return if we already processed everything
|
|
if not remaining_st_line_ids:
|
|
self.write({'cron_last_check': self.env.cr.now()})
|
|
return
|
|
|
|
remaining_st_line_ids -= self._handle_reconciliation_matching_amount(st_move_ids, account_ids, remaining_st_line_ids)
|
|
|
|
# early return if we already processed everything
|
|
if not remaining_st_line_ids:
|
|
self.write({'cron_last_check': self.env.cr.now()})
|
|
return
|
|
|
|
##############################################
|
|
# Reconcile Models
|
|
##############################################
|
|
remaining_st_lines = self.browse(list(remaining_st_line_ids)).with_prefetch(self._prefetch_ids)
|
|
reco_models._apply_reconcile_models(remaining_st_lines)
|
|
_logger.info("try_auto_reconcile - apply reco models - st_lines: %s - reco models %s", remaining_st_lines.ids, reco_models.ids)
|
|
|
|
self.write({'cron_last_check': self.env.cr.now()})
|
|
|
|
def _partner_mapping(self, reco_models):
|
|
"""
|
|
Finds the reconcile model that matches each of the statement lines and assigns the partner of the reconcile model items (if found)
|
|
to the statement line if the account is not set in the item, and the statement line doesn't have a partner set.
|
|
|
|
:param reco_models: recordset of reconcile models to match partners with statement lines
|
|
"""
|
|
self.env['account.reconcile.model'].flush_model()
|
|
self.flush_recordset(['journal_id', 'transaction_details', 'payment_ref', 'company_id'])
|
|
self.env.cr.execute(SQL("""
|
|
WITH matching_journal_ids AS (
|
|
SELECT account_reconcile_model_id,
|
|
ARRAY_AGG(account_journal_id) AS ids
|
|
FROM account_journal_account_reconcile_model_rel
|
|
GROUP BY account_reconcile_model_id
|
|
)
|
|
|
|
SELECT st_line.id AS st_line_id, reco_model.mapped_partner_id
|
|
FROM account_bank_statement_line st_line
|
|
LEFT JOIN LATERAL (
|
|
SELECT reco_model.id,
|
|
reco_model.mapped_partner_id
|
|
FROM account_reconcile_model reco_model
|
|
LEFT JOIN matching_journal_ids ON reco_model.id = matching_journal_ids.account_reconcile_model_id
|
|
WHERE (matching_journal_ids.ids IS NULL OR st_line.journal_id = ANY(matching_journal_ids.ids))
|
|
AND reco_model.mapped_partner_id IS NOT NULL
|
|
AND (
|
|
(
|
|
reco_model.match_label = 'contains'
|
|
AND (
|
|
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
|
|
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
|
)
|
|
) OR (
|
|
reco_model.match_label = 'not_contains'
|
|
AND NOT (
|
|
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
|
|
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
|
)
|
|
) OR (
|
|
reco_model.match_label = 'match_regex'
|
|
AND (
|
|
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ~* reco_model.match_label_param
|
|
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ~* reco_model.match_label_param
|
|
)
|
|
)
|
|
)
|
|
AND reco_model.id = ANY(%s)
|
|
AND reco_model.company_id = st_line.company_id
|
|
ORDER BY reco_model.sequence ASC, reco_model.id ASC
|
|
LIMIT 1
|
|
) AS reco_model ON TRUE
|
|
WHERE st_line.id IN %s
|
|
AND st_line.partner_id IS NULL
|
|
AND reco_model.mapped_partner_id IS NOT NULL
|
|
""", reco_models.ids, tuple(self.ids)))
|
|
|
|
for st_line_id, mapped_partner_id in self.env.cr.fetchall():
|
|
st_line = self.browse(st_line_id).with_prefetch(self._prefetch_ids) # guarantees batch prefetching if needed
|
|
st_line.partner_id = mapped_partner_id
|
|
_logger.info("try_auto_reconcile - partner mapping done for st_line: %s and partner %s", st_line.id, mapped_partner_id)
|
|
|
|
def _end_to_end_uuid(self, st_move_ids, account_ids):
|
|
"""
|
|
Matches the statement lines with paymens using end to end uuid which is generated when the payment is created
|
|
and forwarded through the banking process
|
|
|
|
:param st_move_ids: list of statement line move ids
|
|
:param account_ids: list of account ids that can be used for reconciliation
|
|
:return: set of statement line ids that were successfully matched during this pass
|
|
"""
|
|
processed_st_line_ids = set()
|
|
st_lines_with_end_to_end_uuid_ids = 'end_to_end_uuid' in self._fields and self.filtered('end_to_end_uuid').ids
|
|
if st_lines_with_end_to_end_uuid_ids:
|
|
self.env.cr.execute(SQL("""
|
|
-- Query to get either payment amls either invoice/bill amls related to payments which have
|
|
-- the same end to end uuid of bank statement lines.
|
|
SELECT st_line.id AS st_line_id,
|
|
ARRAY_AGG(aml.id ORDER BY aml.id ASC) AS aml_ids
|
|
FROM account_bank_statement_line st_line
|
|
JOIN account_payment payment ON st_line.end_to_end_uuid = payment.end_to_end_uuid
|
|
JOIN account_move_line aml ON (
|
|
payment.move_id = aml.move_id
|
|
OR aml.move_id IN (
|
|
SELECT move_payment_rel.invoice_id
|
|
FROM account_move__account_payment move_payment_rel
|
|
WHERE move_payment_rel.payment_id = payment.id
|
|
)
|
|
)
|
|
LEFT JOIN res_company aml_company ON aml_company.id = aml.company_id
|
|
LEFT JOIN res_company payment_company ON payment_company.id = payment.company_id
|
|
LEFT JOIN res_company st_line_company ON st_line_company.id = st_line.company_id
|
|
WHERE aml.move_id NOT IN %(st_move_ids)s
|
|
AND (
|
|
aml_company.parent_path LIKE CONCAT(payment_company.id, '/%%')
|
|
OR payment_company.parent_path LIKE CONCAT(aml_company.id, '/%%')
|
|
)
|
|
AND (
|
|
st_line_company.parent_path LIKE CONCAT(payment_company.id, '/%%')
|
|
OR payment_company.parent_path LIKE CONCAT(st_line_company.id, '/%%')
|
|
)
|
|
AND aml.reconciled IS NOT TRUE
|
|
AND aml.account_id IN %(account_ids)s
|
|
AND ((st_line.amount > 0 AND aml.balance > 0) OR (st_line.amount < 0 AND aml.balance < 0))
|
|
AND aml.parent_state in ('draft', 'posted')
|
|
AND st_line.id IN %(st_line_ids)s
|
|
GROUP BY st_line.id
|
|
""", st_move_ids=tuple(st_move_ids), account_ids=tuple(account_ids), st_line_ids=tuple(st_lines_with_end_to_end_uuid_ids)))
|
|
|
|
for st_line_id, aml_ids in self.env.cr.fetchall():
|
|
st_line = self.browse(st_line_id).with_prefetch(self._prefetch_ids) # Guarantees batch prefetching if needed.
|
|
st_line.with_company(st_line.company_id).with_user(SUPERUSER_ID).set_line_bank_statement_line(aml_ids)
|
|
processed_st_line_ids.add(st_line_id)
|
|
|
|
# In case we still have statement lines with end to end uuid and no match, we try to match with single payment
|
|
if st_lines_with_end_to_end_uuid_ids := set(st_lines_with_end_to_end_uuid_ids) - processed_st_line_ids:
|
|
# Get payments without invoices/bills or entries which are matching a bank statement lines
|
|
self.env.cr.execute(SQL("""
|
|
SELECT st_line.id as st_line_id,
|
|
payment.id as payment_id
|
|
FROM account_bank_statement_line st_line
|
|
JOIN account_payment payment ON st_line.end_to_end_uuid = payment.end_to_end_uuid
|
|
LEFT JOIN res_company st_line_company ON st_line_company.id = st_line.company_id
|
|
LEFT JOIN res_company payment_company ON payment_company.id = payment.company_id
|
|
WHERE st_line.id IN %(st_line_ids)s
|
|
AND (
|
|
st_line_company.parent_path LIKE CONCAT(payment_company.id, '/%%')
|
|
OR payment_company.parent_path LIKE CONCAT(st_line_company.id, '/%%')
|
|
)
|
|
AND (
|
|
(st_line.amount > 0 AND payment.payment_type = 'inbound')
|
|
OR (st_line.amount < 0 AND payment.payment_type = 'outbound')
|
|
)
|
|
""", st_line_ids=tuple(st_lines_with_end_to_end_uuid_ids), payment_state=('draft', *self.env['account.batch.payment']._valid_payment_states())))
|
|
for st_line_id, payment_id in self.env.cr.fetchall():
|
|
# Guarantees batch prefetching if needed.
|
|
st_line = self.browse(st_line_id).with_prefetch(self._prefetch_ids)
|
|
payment = self.env['account.payment'].browse(payment_id).with_user(SUPERUSER_ID)
|
|
amls_to_create = payment.with_company(st_line.company_id)._get_amls_for_payment_without_move()
|
|
st_line.with_company(st_line.company_id).with_user(SUPERUSER_ID)._reconcile_payments(payment, amls_to_create)
|
|
processed_st_line_ids.add(st_line_id)
|
|
return processed_st_line_ids
|
|
|
|
def _match_accounts_query(self, st_move_ids, account_ids, remaining_st_line_ids, outstanding_account=False):
|
|
"""
|
|
Reconciles statement lines with entries that have the same payment reference as the statement line's label.
|
|
Used as a generic query between outstanding and default accounts.
|
|
|
|
:param st_move_ids: list of statement line move ids
|
|
:param account_ids: list of account ids that can be used for reconciliation
|
|
:param remaining_st_line_ids: set of remaining statement line ids to process
|
|
:param outstanding_account: boolean to specify the query type
|
|
"""
|
|
aml_domain = Domain.AND((
|
|
Domain('move_id', 'not in', st_move_ids),
|
|
Domain('reconciled', '=', False),
|
|
Domain('parent_state', 'in', ('draft', 'posted')),
|
|
Domain('account_id', 'in', account_ids),
|
|
))
|
|
# Needed for the compute_sql of the sanitize_payment_ref
|
|
aml_query = self.env['account.move.line']._search(aml_domain)
|
|
aml_query.add_where(
|
|
SQL("%s = aml_company.id", self._field_to_sql(aml_query.table, 'company_id'))
|
|
)
|
|
if outstanding_account:
|
|
aml_query.add_where(SQL("%s = st_line.journal_id", self._field_to_sql(aml_query.table, 'journal_id')))
|
|
else:
|
|
aml_query.add_where(SQL("%s = COALESCE(st_line.foreign_currency_id, st_line.currency_id)", self._field_to_sql(aml_query.table, 'currency_id')))
|
|
|
|
query = SQL("""
|
|
SELECT st_line.id,
|
|
ARRAY_AGG(DISTINCT word_aml.id) aml_ids,
|
|
SUM(word_aml.amount_residual),
|
|
word_aml.word matching_word
|
|
FROM account_bank_statement_line st_line
|
|
JOIN LATERAL (
|
|
SELECT account_move_line.id, word, account_move_line.ref, account_move_line.amount_residual
|
|
FROM res_company st_line_company
|
|
JOIN res_company aml_company ON (
|
|
aml_company.parent_path LIKE CONCAT(st_line_company.id, '/%%')
|
|
OR st_line_company.parent_path LIKE CONCAT(aml_company.id, '/%%')
|
|
)
|
|
JOIN %(query_from_clause)s ON (%(query_where_clause)s)
|
|
LEFT JOIN account_move move ON (move.id = account_move_line.move_id AND move.payment_reference != move.name),
|
|
LATERAL regexp_split_to_table(
|
|
COALESCE(account_move_line.ref, '') || ' - ' ||
|
|
COALESCE(account_move_line.move_name, '') || ' - ' ||
|
|
CASE WHEN move.name != move.payment_reference THEN move.payment_reference ELSE '' END || ' - ' ||
|
|
%(sanitize_payment_reference)s, ' - '
|
|
) AS word
|
|
WHERE st_line_company.id = st_line.company_id
|
|
AND (st_line.partner_id IS NULL OR st_line.partner_id = account_move_line.partner_id)
|
|
AND ((st_line.amount > 0 AND account_move_line.balance > 0) OR (st_line.amount < 0 AND account_move_line.balance < 0))
|
|
AND (
|
|
length(word) > 5 AND st_line.payment_ref ILIKE '%%' || word || '%%'
|
|
)
|
|
) word_aml ON TRUE
|
|
WHERE
|
|
st_line.id IN %(remaining_st_line_ids)s
|
|
GROUP BY st_line.id, matching_word
|
|
HAVING COUNT(DISTINCT word_aml.id) = 1
|
|
""",
|
|
remaining_st_line_ids=tuple(remaining_st_line_ids),
|
|
sanitize_payment_reference=self.env['account.move']._field_to_sql('move', 'sanitize_payment_reference'),
|
|
query_from_clause=aml_query.from_clause,
|
|
query_where_clause=aml_query.where_clause,
|
|
)
|
|
|
|
self.env.cr.execute(query)
|
|
|
|
@api.model
|
|
def _is_properly_surrounded(self, text, substring):
|
|
"""
|
|
Definition of what a valid matching word can be: any string, containing whitespaces or not, surrounded by
|
|
start/end of line, whitespace, or punctuation in ['.', ';', ',', '?', '!'].
|
|
"""
|
|
# Escape substring for regex safety
|
|
sub_escaped = re.escape(substring)
|
|
# Allowed delimiters: start (^), end ($), whitespace (\s), or [. ; , ? !]
|
|
pattern = rf"(^|[\s\.;,?!]){sub_escaped}($|[\s\.;,?!])"
|
|
return re.search(pattern, text) is not None
|
|
|
|
def _retrieve_partner(self):
|
|
if not (lines_without_partner := self.filtered(lambda stl: not stl.partner_id)):
|
|
return
|
|
|
|
self.env.flush_all()
|
|
lines_with_bank_number = lines_without_partner.filtered('account_number')
|
|
retrieve_partner_by_account_query = SQL("""
|
|
SELECT ARRAY_AGG(DISTINCT partner_bank.partner_id) FILTER (WHERE partner_bank.company_id::TEXT = ANY(STRING_TO_ARRAY(company.parent_path, '/'))) AS account_matching_partner_with_company,
|
|
ARRAY_AGG(DISTINCT partner_bank.partner_id) FILTER (WHERE partner_bank.company_id IS NULL) AS account_matching_partner_without_company,
|
|
st_line.id AS st_line_id
|
|
FROM res_partner_bank partner_bank
|
|
JOIN account_bank_statement_line st_line ON partner_bank.sanitized_acc_number = NULLIF(REGEXP_REPLACE(st_line.account_number, '\\W+', '', 'g'), '')
|
|
JOIN res_company company ON company.id = st_line.company_id
|
|
JOIN res_partner partner ON partner.id = partner_bank.partner_id
|
|
WHERE st_line.id = ANY(%(st_line_ids)s)
|
|
AND partner.active
|
|
AND partner_bank.active
|
|
GROUP BY st_line.id
|
|
""",
|
|
st_line_ids=lines_with_bank_number.ids,
|
|
)
|
|
|
|
bank_account_matching = {line['st_line_id']: {
|
|
'account_matching_partner_with_company': line['account_matching_partner_with_company'] or [],
|
|
'account_matching_partner_without_company': line['account_matching_partner_without_company'] or [],
|
|
} for line in self.env.execute_query_dict(retrieve_partner_by_account_query)}
|
|
|
|
for st_line in lines_with_bank_number:
|
|
if not (match_account := bank_account_matching.get(st_line.id)):
|
|
continue
|
|
# Retrieve the partner from the bank account.
|
|
if len(partner := match_account['account_matching_partner_with_company']) == 1:
|
|
# First match if company match
|
|
st_line.partner_id = partner[0]
|
|
elif len(partner := match_account['account_matching_partner_without_company']) == 1:
|
|
# Second match if company doesn't match
|
|
st_line.partner_id = partner[0]
|
|
|
|
if not (lines_without_partner := lines_without_partner.filtered(lambda stl: not stl.partner_id)):
|
|
return
|
|
|
|
# Exclude some partners from the retrieve partner functionnality
|
|
blacklisted_partners = self._get_blacklisted_partners()
|
|
lines_with_partner_name = lines_without_partner.filtered('partner_name')
|
|
retrieve_partner_by_name_query = SQL("""
|
|
SELECT ARRAY_AGG(partner.id) FILTER (WHERE partner.complete_name ILIKE st_line.partner_name AND partner.company_id::TEXT = ANY(STRING_TO_ARRAY(company.parent_path, '/'))) AS full_name_matching_partner_with_company,
|
|
ARRAY_AGG(partner.id) FILTER (WHERE partner.complete_name ILIKE st_line.partner_name AND partner.company_id IS NULL) AS full_name_matching_partner_without_company,
|
|
ARRAY_AGG(partner.id) FILTER (WHERE partner.complete_name ILIKE '%%' || st_line.partner_name || '%%' AND partner.company_id::TEXT = ANY(STRING_TO_ARRAY(company.parent_path, '/'))) AS partial_name_matching_partner_with_company,
|
|
ARRAY_AGG(partner.id) FILTER (WHERE partner.complete_name ILIKE '%%' || st_line.partner_name || '%%' AND partner.company_id IS NULL) AS partial_name_matching_partner_without_company,
|
|
st_line.id AS st_line_id
|
|
FROM res_partner partner
|
|
JOIN account_bank_statement_line st_line ON partner.complete_name ILIKE '%%' || NULLIF(TRIM(st_line.partner_name), '') || '%%'
|
|
JOIN res_company company ON company.id = st_line.company_id
|
|
WHERE partner.parent_id IS NULL
|
|
AND st_line.id = ANY(%(st_line_ids)s)
|
|
AND partner.id != ANY(%(blacklisted_partner_ids)s)
|
|
AND partner.active
|
|
GROUP BY st_line.id
|
|
""",
|
|
st_line_ids=lines_with_partner_name.ids,
|
|
blacklisted_partner_ids=blacklisted_partners.ids,
|
|
)
|
|
|
|
partner_name_matching = {line['st_line_id']: {
|
|
'full_name_matching_partner_with_company': line['full_name_matching_partner_with_company'] or [],
|
|
'full_name_matching_partner_without_company': line['full_name_matching_partner_without_company'] or [],
|
|
'partial_name_matching_partner_with_company': line['partial_name_matching_partner_with_company'] or [],
|
|
'partial_name_matching_partner_without_company': line['partial_name_matching_partner_without_company'] or [],
|
|
} for line in self.env.execute_query_dict(retrieve_partner_by_name_query)}
|
|
|
|
for st_line in lines_with_partner_name:
|
|
if not (match_partner_name := partner_name_matching.get(st_line.id)):
|
|
continue
|
|
# Retrieve the partner from the partner name.
|
|
if len(partner := match_partner_name['full_name_matching_partner_with_company']) == 1:
|
|
# First match if partner name full match and company match
|
|
st_line.partner_id = partner[0]
|
|
elif len(partner := match_partner_name['full_name_matching_partner_without_company']) == 1:
|
|
# Second match if partner name full match and company doesn't
|
|
st_line.partner_id = partner[0]
|
|
elif len(partner := match_partner_name['partial_name_matching_partner_with_company']) == 1:
|
|
# Third match if partner name partially match and company match
|
|
st_line.partner_id = partner[0]
|
|
elif len(partner := match_partner_name['partial_name_matching_partner_without_company']) == 1:
|
|
# Fourth match if partner name partially match and company doesn't
|
|
st_line.partner_id = partner[0]
|
|
|
|
if not (lines_with_partner_name := lines_with_partner_name.filtered(lambda stl: not stl.partner_id)):
|
|
return
|
|
|
|
# In case we don't find the partner with the above conditions, we will retrieve it
|
|
# from existing statement lines
|
|
retrieve_partner_from_st_line_query = SQL("""
|
|
WITH st_lines AS (
|
|
SELECT st_line.partner_id AS partner_id,
|
|
st_line.partner_name AS partner_name,
|
|
st_line.company_id as company_id,
|
|
ROW_NUMBER() OVER (PARTITION BY st_line.partner_name ORDER BY st_line.id DESC) AS row_number
|
|
FROM account_bank_statement_line st_line
|
|
JOIN res_partner partner ON partner.id = st_line.partner_id
|
|
WHERE st_line.is_reconciled = TRUE
|
|
AND st_line.partner_name = ANY (%(partner_names)s)
|
|
AND st_line.company_id IN %(company_ids)s
|
|
AND partner.active
|
|
GROUP BY st_line.id
|
|
ORDER BY st_line.id DESC
|
|
)
|
|
SELECT MIN(partner_id) AS partner_id,
|
|
ARRAY_AGG(DISTINCT company_id) as company_ids,
|
|
partner_name
|
|
FROM st_lines
|
|
WHERE st_lines.row_number <= 3
|
|
GROUP BY partner_name
|
|
HAVING COUNT(DISTINCT partner_id) = 1
|
|
""",
|
|
partner_names=lines_with_partner_name.mapped('partner_name'),
|
|
company_ids=(*self.company_id.ids, None),
|
|
)
|
|
self.env.cr.execute(retrieve_partner_from_st_line_query)
|
|
query_res_lines = self.env.cr.dictfetchall()
|
|
partners_from_previous_st_line = {
|
|
res_line['partner_name']: {
|
|
'partner_id': res_line['partner_id'],
|
|
'company_ids': res_line['company_ids'],
|
|
}
|
|
for res_line in query_res_lines
|
|
}
|
|
|
|
for st_line in lines_with_partner_name:
|
|
if not (match_partner := partners_from_previous_st_line.get(st_line.partner_name)):
|
|
continue
|
|
# Last check, if there is no partner yet, try to match with previous statement lines.
|
|
if st_line.company_id.id in match_partner['company_ids']:
|
|
st_line.partner_id = match_partner['partner_id']
|
|
|
|
def _get_blacklisted_partners(self):
|
|
partners = self.env.ref("base.partner_root")
|
|
if ai_agent := self.env.ref("ai.ai_default_agent", raise_if_not_found=False):
|
|
partners += ai_agent.partner_id
|
|
return partners
|
|
|
|
def _action_manual_reco_model(self, reco_model_id):
|
|
self.move_id.line_ids.filtered(lambda x: x.account_id == x.move_id.journal_id.suspense_account_id).reconcile_model_id = reco_model_id
|
|
|
|
def _get_counterpart_aml(self, open_balance, open_amount_currency, is_same_currency):
|
|
""" Generates a counterpart account move line based on the given open balance.
|
|
|
|
:param open_balance: The open balance amount that will be used to create the counterpart account move line.
|
|
:returns: A dictionary containing the values to create a counterpart account move line, with
|
|
keys like "name", "account_id", "amount_currency", and "currency_id".
|
|
"""
|
|
self.ensure_one()
|
|
currency = self.foreign_currency_id or self.currency_id or self.journal_id.currency_id or self.company_id.currency_id
|
|
return {
|
|
'name': self.payment_ref,
|
|
'account_id': self.journal_id.suspense_account_id.id,
|
|
'balance': -open_balance,
|
|
'currency_id': currency.id,
|
|
'amount_currency': -open_amount_currency if is_same_currency else currency.round(-open_balance * currency.with_company(self.company_id).rate),
|
|
}
|
|
|
|
def _get_partner_id(self, lines_to_add_partner_ids):
|
|
""" Determines the appropriate partner ID based on the set of partner IDs
|
|
passed to it.
|
|
|
|
:param lines_to_add_partner_ids: A set of partner IDs to evaluate.
|
|
:returns: The partner ID if there is exactly one unique partner ID, the current
|
|
partner ID if the set is empty, or `None` if there are multiple partner IDs.
|
|
|
|
"""
|
|
self.ensure_one()
|
|
if len(lines_to_add_partner_ids) == 1:
|
|
return lines_to_add_partner_ids.pop()
|
|
if len(lines_to_add_partner_ids) == 0:
|
|
return self.partner_id.id
|
|
return None
|
|
|
|
def _set_move_line_to_statement_line_move(self, lines_to_set, lines_to_add):
|
|
""" Updates the bank statement line by setting the provided move lines
|
|
(`lines_to_set`) and adding new move lines (`lines_to_add`). It also handles creating
|
|
a counterpart move line if necessary and associates the correct partner and bank account
|
|
details.
|
|
|
|
:param lines_to_set: A recordset of move lines that are already associated
|
|
with the bank statement line.
|
|
:param lines_to_add: A list of dictionaries representing the move lines
|
|
to be added to the bank statement line.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
lines_to_add_balance = sum(line['balance'] for line in lines_to_add)
|
|
lines_commands = [Command.set(lines_to_set.ids)] + [Command.create(line_to_add) for line_to_add in lines_to_add]
|
|
|
|
open_balance = sum(lines_to_set.mapped('balance')) + lines_to_add_balance
|
|
if not self.company_currency_id.is_zero(open_balance):
|
|
if not self.foreign_currency_id:
|
|
lines_to_add_amount_currency = sum(line['amount_currency'] for line in lines_to_add)
|
|
open_amount_currency = sum(lines_to_set.mapped('amount_currency')) + lines_to_add_amount_currency
|
|
is_same_currency = len(lines_to_set.currency_id) == 1 and all(line['currency_id'] == lines_to_set.currency_id.id for line in lines_to_add)
|
|
else:
|
|
liquidity_lines, _suspense_lines, _other_lines = self._seek_for_lines()
|
|
lines_to_add_amount_currency = sum(line['amount_currency'] for line in lines_to_add)
|
|
open_amount_currency = self.amount_currency + sum((lines_to_set - liquidity_lines).mapped('amount_currency')) + lines_to_add_amount_currency
|
|
# We know that the liquidity is in foreign so
|
|
is_same_currency = len(lines_to_set.currency_id) == 1 and all(line['currency_id'] == self.foreign_currency_id.id for line in lines_to_add)
|
|
lines_commands.append(Command.create(
|
|
self._get_counterpart_aml(open_balance, open_amount_currency, is_same_currency)
|
|
))
|
|
move = self.move_id.with_context(force_delete=True, skip_readonly_check=True)
|
|
move.line_ids = lines_commands
|
|
|
|
# We only want to recompute the partner when it's using the reconcile button, not when deleting or editing lines
|
|
if self.env.context.get('recompute_partner'):
|
|
partner_id = self._get_partner_id({line['partner_id'] for line in lines_to_add if line.get('partner_id')})
|
|
partner = self.env['res.partner'].browse(partner_id)
|
|
if partner:
|
|
# To avoid "Incompatible companies on records" error, make sure the user is linked to a main company.
|
|
allowed_companies = partner.company_id.root_id
|
|
if len(lines_to_set.company_id) == 1:
|
|
# Or the user is linked to the st_line's company.
|
|
allowed_companies |= lines_to_set.company_id
|
|
# Or the user is not linked to any company.
|
|
if not partner.company_id or partner.company_id in allowed_companies:
|
|
move.line_ids.filtered(lambda line: not line.partner_id).partner_id = partner
|
|
|
|
# Create missing partner bank if necessary.
|
|
if self.account_number and self.partner_id:
|
|
partner_id = self._get_partner_id({line['partner_id'] for line in lines_to_add if line.get('partner_id')})
|
|
partner = self.env['res.partner'].browse(partner_id)
|
|
partner_bank = self.env['res.partner.bank'].search([
|
|
('acc_number', '=', self.account_number),
|
|
])
|
|
if len(partner_bank) > 1:
|
|
partner_bank = partner_bank.filtered(lambda pb: pb.partner_id == partner_id)
|
|
self.with_context(
|
|
skip_account_move_synchronization=True,
|
|
skip_readonly_check=True,
|
|
).partner_bank_id = len(partner_bank) == 1 and partner_bank or self._find_or_create_bank_account()
|
|
if partner and partner != self.partner_bank_id.partner_id:
|
|
self.env.user._bus_send('switch_partner_account_notification', {
|
|
'current_partner_name': self.partner_bank_id.partner_id.name,
|
|
'potential_partner_name': partner.name,
|
|
'account_number': self.account_number,
|
|
'partner_bank_id': self.partner_bank_id.id,
|
|
'potential_partner_id': partner.id,
|
|
})
|
|
|
|
self._post_matching_done_confirmation()
|
|
|
|
def _get_last_5_minutes_messages(self, body):
|
|
self.ensure_one()
|
|
return self.move_id.message_ids.filtered_domain([
|
|
('author_id', '=', self.env.user.partner_id.id),
|
|
('create_date', '>=', fields.Datetime.now() - relativedelta(minutes=5)),
|
|
('body', 'ilike', body),
|
|
])
|
|
|
|
def _post_matching_done_confirmation(self):
|
|
self.ensure_one()
|
|
if not self.is_reconciled:
|
|
return
|
|
if self._get_last_5_minutes_messages(body='Matching done'):
|
|
return
|
|
|
|
body = _("Matching done")
|
|
if reconcile_model := self.move_id.line_ids.reconcile_model_id:
|
|
body += _(" - %(reconcile_model_name)s", reconcile_model_name=", ".join(reconcile_model.mapped("name")))
|
|
self.move_id.message_post(
|
|
body=body,
|
|
author_id=self.env.user.partner_id.id,
|
|
)
|
|
|
|
def _post_matching_unreconciled(self):
|
|
self.ensure_one()
|
|
if not self.is_reconciled:
|
|
return
|
|
if self._get_last_5_minutes_messages(body='Matching unreconciled'):
|
|
return
|
|
self.move_id.message_post(
|
|
body=_('Matching unreconciled'),
|
|
author_id=self.env.user.partner_id.id,
|
|
)
|
|
|
|
def _add_move_line_to_statement_line_move(self, lines_to_add):
|
|
""" Adds move lines to the bank statement line and updates the reconciliation.
|
|
|
|
:param lines_to_add: A list of move line values to be added to the bank statement line.
|
|
"""
|
|
self.ensure_one()
|
|
liquidity_lines, _suspense_lines, other_lines = self._seek_for_lines()
|
|
merged_lines = []
|
|
tax_line_by_key = {}
|
|
for line in lines_to_add:
|
|
if not line.get('tax_repartition_line_id') or line.get('reconciled_lines_ids'):
|
|
merged_lines.append(line)
|
|
continue
|
|
key = frozendict({
|
|
'account_id': line.get('account_id'),
|
|
'partner_id': line.get('partner_id'),
|
|
'currency_id': line.get('currency_id'),
|
|
'analytic_distribution': line.get('analytic_distribution'),
|
|
'tax_repartition_line_id': line.get('tax_repartition_line_id'),
|
|
'group_tax_id': line.get('group_tax_id'),
|
|
'tax_ids': line.get('tax_ids') or [],
|
|
'tax_tag_ids': line.get('tax_tag_ids') or [],
|
|
})
|
|
existing = tax_line_by_key.get(key)
|
|
if existing is None:
|
|
tax_line_by_key[key] = dict(line)
|
|
merged_lines.append(tax_line_by_key[key])
|
|
else:
|
|
existing['balance'] += line['balance']
|
|
existing['amount_currency'] += line['amount_currency']
|
|
self._set_move_line_to_statement_line_move(liquidity_lines + other_lines, merged_lines)
|
|
|
|
def set_partner_bank_statement_line(self, partner_id):
|
|
""" Sets the partner for the bank statement line.
|
|
|
|
:param partner_id: The ID of the partner to set on the bank statement line.
|
|
"""
|
|
all_statement_lines = self
|
|
if partner_names := [line.partner_name for line in self if line.partner_name]:
|
|
# We get the base statement lines + the ones with the same partner_name
|
|
all_statement_lines |= self.search([
|
|
('journal_id', '=', self.journal_id.id),
|
|
('partner_name', 'in', partner_names),
|
|
('is_reconciled', '=', False),
|
|
('partner_id', '=', False),
|
|
])
|
|
|
|
(all_statement_lines - self).move_id._track_set_author(self.env.ref('base.partner_root'))
|
|
all_statement_lines.with_context(force_delete=True, skip_readonly_check=True).partner_id = partner_id
|
|
all_statement_lines._try_auto_reconcile_statement_lines()
|
|
|
|
def set_account_bank_statement_line(self, aml_id, account_id):
|
|
""" Sets the specified account to the given account move line.
|
|
Also creates a reco model for fees for this journal and this account if it's in the 3% range
|
|
Also can delete or try to create new reco model depending on the pattern and if a model is created, returns
|
|
the any unreconciled statement lines that can now use the new rule for matching for the JS to reload them.
|
|
|
|
:param aml_id: The IDs of the account move lines to update.
|
|
:param account_id: The ID of the account to set on the specified account move line.
|
|
:return: The recordset of unreconciled statement lines that can now use the new rule for matching, if created.
|
|
"""
|
|
account = self.env['account.account'].browse(account_id)
|
|
# To be able to work with multiple aml_ids, needed for the return value
|
|
statement_lines = self
|
|
|
|
base_lines = self.env['account.move.line'].browse(aml_id)
|
|
for statement_line, base_line in zip(statement_lines, base_lines):
|
|
suspense_account_id = statement_line.journal_id.suspense_account_id.id
|
|
liquidity_account_id = statement_line.journal_id.default_account_id.id
|
|
statement_line._create_account_model_fee(account_id)
|
|
# Get the original base lines and tax lines before the modification of the line
|
|
if account.tax_ids:
|
|
original_base_lines, original_tax_lines = statement_line._prepare_for_tax_lines_recomputation()
|
|
|
|
base_line.account_id = account
|
|
base_line.move_id._compute_checked()
|
|
|
|
# Now that the line has been modified, we can recompute the taxes
|
|
if account.tax_ids:
|
|
statement_line._create_tax_lines(original_base_lines, original_tax_lines, base_line)
|
|
# The function above will add a tax line below, so we get the last set account line
|
|
base_line = statement_line.line_ids.filtered(lambda line: line.tax_ids)[-1:]
|
|
|
|
if account.account_type in {'asset_receivable', 'liability_payable'} or account in {suspense_account_id, liquidity_account_id}:
|
|
statement_line._post_matching_done_confirmation()
|
|
return statement_lines
|
|
|
|
statement_lines |= statement_line._create_automatic_reconciliation_model(base_line, account)
|
|
return statement_lines
|
|
|
|
def _create_automatic_reconciliation_model(self, account_move_line, account):
|
|
"""
|
|
This function will deal with the creation or not of the automatic reconciliation model
|
|
"""
|
|
self._handle_reconciliation_rule(account_move_line, account.id)
|
|
new_rule = self._check_and_create_reconciliation_rule(account.id, self.company_id.id)
|
|
|
|
if new_rule:
|
|
return self.env['account.bank.statement.line'].search([
|
|
('journal_id', '=', self.journal_id.id),
|
|
('is_reconciled', '=', False),
|
|
('move_id.line_ids.reconcile_model_id', '=', new_rule.id),
|
|
])
|
|
|
|
self._post_matching_done_confirmation()
|
|
return self.env['account.bank.statement.line']
|
|
|
|
def _handle_reconciliation_rule(self, aml, account_id):
|
|
# If a rule has been created by Odoo and is recommended to the user but another account is chosen, the rule
|
|
# should be deleted.
|
|
should_delete_rule = (
|
|
aml.reconcile_model_id
|
|
and aml.reconcile_model_id.create_uid.id == SUPERUSER_ID
|
|
and account_id not in aml.reconcile_model_id.line_ids.account_id.ids
|
|
)
|
|
if should_delete_rule:
|
|
aml.reconcile_model_id.sudo().unlink()
|
|
|
|
def _check_and_create_reconciliation_rule(self, account_id, company_id):
|
|
"""Checks if a reconciliation rule exists for the given account and company. If not, attempts to create one
|
|
based on previous statement line patterns.
|
|
|
|
:param account_id: ID of the account to check for rules
|
|
:param company_id: ID of the company
|
|
:return: The newly created reconciliation rule if created, None otherwise
|
|
"""
|
|
bank_stmt_line_domain = [
|
|
('company_id', '=', company_id),
|
|
('journal_id', '=', self.journal_id.id),
|
|
('payment_ref', '!=', False),
|
|
('is_reconciled', '=', True),
|
|
('move_id.line_ids.account_id', '=', account_id),
|
|
]
|
|
previous_statement_lines = self.env['account.bank.statement.line'].search(
|
|
bank_stmt_line_domain, limit=5, order='internal_index desc'
|
|
)
|
|
if len(previous_statement_lines) <= 1:
|
|
return None
|
|
|
|
if all(line.amount >= 0 for line in previous_statement_lines):
|
|
match_amount = 'greater'
|
|
elif all(line.amount <= 0 for line in previous_statement_lines):
|
|
match_amount = 'lower'
|
|
else:
|
|
match_amount = False
|
|
|
|
# removing the statement lines from previous statement lines which are already eligible for one of the reco model
|
|
# but reco model does not applied to it yet, because we want to allow multiple reco model with same account for different payment ref
|
|
existing_reco_models = self.env['account.reconcile.model'].search([
|
|
('company_id', '=', company_id),
|
|
('match_journal_ids', '=', self.journal_id.ids),
|
|
('match_label', '=', 'contains'),
|
|
('match_amount', '=', match_amount),
|
|
('match_amount_min', '=', 0),
|
|
('match_amount_max', '=', 0),
|
|
('match_label_param', '!=', False),
|
|
])
|
|
for reco_model in existing_reco_models:
|
|
# If the line shares a common substring with an existing rule and has the same account the match_label of the rule should be changed to the common substring
|
|
common_substring = self._get_common_substring([reco_model.match_label_param, self.payment_ref])
|
|
if common_substring and not common_substring in existing_reco_models.mapped('match_label_param') and reco_model.line_ids.account_id.id == account_id and reco_model.created_automatically:
|
|
reco_model.match_label_param = common_substring
|
|
|
|
previous_statement_lines = previous_statement_lines.filtered(lambda sl: sl.payment_ref and reco_model.match_label_param not in sl.payment_ref.upper())
|
|
if len(previous_statement_lines) <= 1:
|
|
return None
|
|
|
|
rule_data = self._prepare_reconciliation_rule_data(previous_statement_lines, account_id, match_amount)
|
|
if rule_data.get('common_substring'):
|
|
return self._create_reconciliation_rule(rule_data)
|
|
return None
|
|
|
|
def _prepare_reconciliation_rule_data(self, statement_lines, account_id, match_amount=False):
|
|
"""Prepares data for reconciliation rule creation."""
|
|
payment_refs = [line.payment_ref.strip() for line in statement_lines]
|
|
common_substring = self._get_common_substring(payment_refs)
|
|
if not common_substring:
|
|
return {}
|
|
account = self.env['account.account'].browse(account_id)
|
|
|
|
return {
|
|
'name': account.name,
|
|
'common_substring': common_substring,
|
|
'account': account,
|
|
'partner_ids': statement_lines.partner_id.ids if len(statement_lines.partner_id.ids) == 1 else [],
|
|
'match_amount': match_amount,
|
|
}
|
|
|
|
def _create_reconciliation_rule(self, rule_data):
|
|
"""Creates and returns a new reconciliation rule based on prepared data."""
|
|
vals = {
|
|
'created_automatically': True,
|
|
'name': rule_data['name'],
|
|
'match_journal_ids': self.journal_id.ids,
|
|
'match_label': 'contains',
|
|
'match_label_param': rule_data['common_substring'],
|
|
'match_amount': rule_data['match_amount'],
|
|
'match_amount_min': 0,
|
|
'match_amount_max': 0,
|
|
'line_ids': [
|
|
Command.create({
|
|
'account_id': rule_data['account'].id,
|
|
'amount_type': 'percentage',
|
|
'amount_string': '100',
|
|
}),
|
|
],
|
|
}
|
|
|
|
if rule_data['partner_ids']:
|
|
vals['match_partner_ids'] = rule_data['partner_ids']
|
|
|
|
return self.with_user(SUPERUSER_ID).with_company(self.journal_id.company_id).env['account.reconcile.model'].create(vals)
|
|
|
|
def _get_common_substring(self, labels):
|
|
"""
|
|
Returns the normalised longest common substring that is at least 10 characters long from a list
|
|
of labels. For shorter substrings, returns the normalised label if all labels are identical after
|
|
normalisation, otherwise returns None.
|
|
|
|
:param labels: List of string labels to process
|
|
:return: Longest common substring if 10+ chars, normalised label if all identical post-normalisation, otherwise None
|
|
"""
|
|
|
|
def common_substrings(label, olabel):
|
|
olabel_substring_positions = defaultdict(list)
|
|
# Go through olabel and store 10 chars substring end positions.
|
|
for idx in range(len(olabel) - 9):
|
|
olabel_substring_positions[olabel[idx:idx + 10]].append(idx + 9)
|
|
# Go through label, generate substrings common with olabel.
|
|
# We need substrings >= 10 chars so we start the comparison at 10 chars and add 1 char at a time.
|
|
for label_starting_pos in range(len(label) - 9):
|
|
for olabel_end_pos in olabel_substring_positions[label[label_starting_pos:label_starting_pos + 10]]:
|
|
label_end_pos = label_starting_pos + 9
|
|
current_substring = label[label_starting_pos:label_end_pos]
|
|
while label_end_pos < len(label) and olabel_end_pos < len(olabel) and label[label_end_pos] == olabel[olabel_end_pos]:
|
|
current_substring += label[label_end_pos]
|
|
label_end_pos += 1
|
|
olabel_end_pos += 1
|
|
yield current_substring.strip()
|
|
|
|
labels = [label.upper().strip() for label in labels if label]
|
|
if len(labels) <= 1:
|
|
return None
|
|
|
|
# If they're all the same, then we don't care about the size being 10 chars or more.
|
|
if all(label == labels[0] for label in labels[1:]):
|
|
return labels[0]
|
|
# Sorting by length, so we start with the shortest strings first. This will allow us to have the
|
|
# substrings intersection between the two smallest labels. This reduces the
|
|
# intersection computation time with the largest labels.
|
|
labels.sort(key=len)
|
|
|
|
# To achieve this:
|
|
# 1. Use `common_substrings` on the smallest labels to get the minimal intersection.
|
|
# 2. Iterate over the minimal intersection.
|
|
# 3. Check if a substring is also in the remaining labels only if it's longer than the current max.
|
|
# 4. If a common substring exists, return it. If not, return None as default.
|
|
longest_substring = ""
|
|
for substring in common_substrings(labels[0], labels[1]):
|
|
if len(substring) > len(longest_substring) and all(substring in label for label in labels[2:]) and len(substring) >= 10:
|
|
longest_substring = substring
|
|
return longest_substring or None
|
|
|
|
def _create_account_model_fee(self, account_id):
|
|
"""
|
|
In case of a statement line nearly matching an invoice (entering money on the statement line),
|
|
when a user puts the leftover on an account,
|
|
create a new model for these type of fees with the account if it does not exist already
|
|
"""
|
|
def create_reco_model_xml_id(name, journal):
|
|
self.env['account.reconcile.model']._load_records([{
|
|
'xml_id': f'account.account_reco_model_fee_{journal.id}',
|
|
'values': {
|
|
'company_id': journal.company_id.id,
|
|
'match_journal_ids': journal.ids,
|
|
'name': name,
|
|
'line_ids': [Command.create({
|
|
'account_id': account_id,
|
|
'label': _('Bank Fees'),
|
|
'amount_type': 'percentage',
|
|
'amount_string': '100',
|
|
})]
|
|
},
|
|
}])
|
|
|
|
tolerance = self._get_payment_tolerance()
|
|
if (
|
|
self.currency_id.compare_amounts(self.amount, 0) < 0
|
|
or self.currency_id.compare_amounts(self.amount_residual, 0) < 0
|
|
or (not float_is_zero(tolerance, 6) and self.currency_id.compare_amounts(abs(self.amount_residual), tolerance * (self.amount_currency if self.foreign_currency_id else self.amount)) > 0)
|
|
):
|
|
return
|
|
|
|
journal = self.journal_id
|
|
if self.env.ref(f'account.account_reco_model_fee_{journal.id}', raise_if_not_found=False):
|
|
return
|
|
|
|
base_model_name = f'Fees ({journal.name})'
|
|
existing_journal_names = set(
|
|
self.env['account.reconcile.model'].search_fetch(
|
|
[
|
|
('name', 'like', base_model_name + '%'),
|
|
*self.env['account.journal']._check_company_domain(journal.company_id)
|
|
],
|
|
['name'],
|
|
).mapped('name')
|
|
)
|
|
if base_model_name not in existing_journal_names:
|
|
create_reco_model_xml_id(base_model_name, journal)
|
|
else:
|
|
for num in range(2, 100):
|
|
new_model_name = f'{base_model_name} {num}'
|
|
if new_model_name not in existing_journal_names:
|
|
create_reco_model_xml_id(new_model_name, journal)
|
|
return
|
|
|
|
# If we could not find a valid code due to multiple journals with the same name,
|
|
# do it with the journal name and the journal code (which is unique)
|
|
create_reco_model_xml_id(f'Fees ({journal.name} - {journal.code})', journal)
|
|
|
|
def _is_company_amount_exceeded(self, company_currency, cumulated_balance, company_amount):
|
|
"""
|
|
Helper to check if a transaction amount is already exceeded by the cumulated balance
|
|
Its main purpose is to have more readable conditions in `set_line_bank_statement_line` method
|
|
"""
|
|
return company_currency.compare_amounts(abs(cumulated_balance), abs(company_amount)) > 0
|
|
|
|
def _will_company_amount_exceed(self, company_currency, cumulated_balance, next_balance, company_amount):
|
|
"""
|
|
Helper to check if a transaction amount will be exceeded by `cumulated balance + next_balance`
|
|
Its main purpose is to have more readable conditions in `set_line_bank_statement_line` method
|
|
"""
|
|
return (self._is_company_amount_exceeded(company_currency, cumulated_balance, company_amount)
|
|
and company_currency.compare_amounts(abs(cumulated_balance + next_balance), abs(company_amount)) > 0)
|
|
|
|
def _create_payment_with_move_from_invoice(self, move_id):
|
|
""" Creates a payment for the given move and forces a move on it.
|
|
Can be useful for simplifying operations like computing exchange differences
|
|
for invoice with an early payment discount.
|
|
|
|
:param move_id: the account move for which a payment should be created
|
|
:return: the line from the payment move that can be reconciled in the bank reconciliation widget
|
|
"""
|
|
return self.env['account.payment.register']\
|
|
.with_context(
|
|
active_model='account.move',
|
|
active_ids=move_id.ids,
|
|
force_payment_move=True,
|
|
).create({
|
|
'payment_date': self.date,
|
|
})._create_payments()
|
|
|
|
def _convert_amount_to_transaction_currency(self, currency_id, amount_currency, balance):
|
|
transaction_amount, transaction_currency, journal_amount, journal_currency, company_amount, _company_currency = self._get_accounting_amounts_and_currencies()
|
|
|
|
if currency_id == transaction_currency:
|
|
return amount_currency
|
|
if currency_id == journal_currency:
|
|
journal_transaction_rate = abs(transaction_amount / journal_amount) if journal_amount else 0.0
|
|
return transaction_currency.round(amount_currency * journal_transaction_rate)
|
|
company_transaction_rate = abs(transaction_amount / company_amount) if company_amount else 0.0
|
|
return transaction_currency.round(balance * company_transaction_rate)
|
|
|
|
def _apply_early_payment_discount(self, move_line, open_amount_currency, transaction_currency, exchange_diff_balance):
|
|
""" Apply the early payment discount if possible.
|
|
In the case when a exchange difference has to be applied along an early payment discount,
|
|
a payment with move is created to properly handle the exchange difference on the EPD.
|
|
|
|
:param move_line: the account move line for which the EPD should be computed/added
|
|
:param open_amount_currency: the open amount in currency of the statement line at the time of computing the move line values dict
|
|
:param transaction_currency: the currency in which the amount currency is expressed
|
|
:param exchange_diff_balance: the exchange difference amount computed for the base move_line to add
|
|
|
|
:return: epd_lines_vals: the list of dictionaries for all the EPD computed values,
|
|
the first one being the discounted move line values dict. Can be empty if no EPD could be applied.
|
|
total_amount: in company currency, the total amount the base line amount minus the EPD represents
|
|
total_amount_currency: in transaction currency, the total amount the base line amount minus the EPD represents
|
|
If a payment with move had to be created, the epd_lines_vals list will only contain the dict of the reconcilable line of that move
|
|
"""
|
|
epd_lines_vals = []
|
|
epd_amount_currency = move_line.amount_currency - move_line.discount_amount_currency
|
|
total_amount = total_amount_currency = 0.0
|
|
if (
|
|
move_line.move_id._is_eligible_for_early_payment_discount(transaction_currency, self.date)
|
|
and self._qualifies_for_early_payment(transaction_currency, open_amount_currency, epd_amount_currency)
|
|
):
|
|
if not move_line.currency_id.is_zero(exchange_diff_balance):
|
|
# In the case the base aml has an exhange diff, the EPD will have one too.
|
|
# And exchange diffs on EPD are hard to handle as an EPD has no counterpart lines to reconcile it with,
|
|
# unlike basic amls, where the exch diff is computed when reconciling the invoice line with the statement_line line.
|
|
# To counter that, we'll create a new payment with a journal entry to correctly compute all necessary diffs.
|
|
payment_with_move = self._create_payment_with_move_from_invoice(move_line.move_id)
|
|
payment_line_to_add = payment_with_move.move_id.line_ids.filtered_domain(self._get_default_amls_matching_domain())
|
|
epd_lines_vals = [payment_line_to_add._get_aml_values(
|
|
balance=-payment_line_to_add.balance,
|
|
amount_currency=-payment_line_to_add.amount_currency,
|
|
reconciled_lines_ids=[Command.set(payment_line_to_add.ids)],
|
|
)]
|
|
total_amount = payment_line_to_add.balance
|
|
total_amount_currency = self._convert_amount_to_transaction_currency(payment_line_to_add.currency_id, payment_line_to_add.amount_currency, payment_line_to_add.balance)
|
|
else:
|
|
early_pay_aml_values_list = [{
|
|
'aml': move_line,
|
|
'amount_currency': -move_line.amount_currency,
|
|
'balance': -move_line.amount_residual,
|
|
}]
|
|
epd_lines_vals = [
|
|
move_line._get_aml_values(
|
|
balance=-move_line.amount_residual,
|
|
amount_currency=-move_line.amount_residual_currency,
|
|
reconciled_lines_ids=[Command.set(move_line.ids)],
|
|
),
|
|
*self._set_early_payment_discount_lines(early_pay_aml_values_list, 0),
|
|
]
|
|
for vals in epd_lines_vals:
|
|
total_amount -= vals['balance']
|
|
total_amount_currency -= self._convert_amount_to_transaction_currency(move_line.currency_id, vals['amount_currency'], vals['balance'])
|
|
return epd_lines_vals, total_amount, total_amount_currency
|
|
|
|
def set_line_bank_statement_line(self, move_lines_ids):
|
|
""" Sets the specified move lines to the bank statement line and performs reconciliation.
|
|
|
|
:param move_lines_ids: A list of IDs for the move lines to be added to the bank statement line.
|
|
"""
|
|
self.ensure_one()
|
|
# We do not want to set a line that is already reconciled, otherwise a user error would be raised. The order
|
|
# is there to keep the same order as the one received in move_line_ids
|
|
move_lines = self.env['account.move.line'].search([
|
|
('id', 'in', move_lines_ids),
|
|
('reconciled', '=', False),
|
|
], order="sequence DESC")
|
|
if not move_lines:
|
|
return
|
|
|
|
_liquidity_line, _suspense_lines, other_lines = self._seek_for_lines()
|
|
|
|
transaction_amount, transaction_currency, _journal_amount, _journal_currency, company_amount, company_currency = self._get_accounting_amounts_and_currencies()
|
|
|
|
open_balance = company_amount
|
|
open_amount_currency = transaction_amount
|
|
|
|
for line in other_lines:
|
|
# move_lines are the lines coming from the reconcile button and other_lines are the lines from the bank
|
|
# statement move (so they are reconciled). We need to invert the sign of move_lines since a positive
|
|
# move_line need to be put as negative in the bank statement move.
|
|
# For the other lines, we can use the balance and amount currency since they are the line on the bank move
|
|
open_balance += line.balance
|
|
open_amount_currency += self._convert_amount_to_transaction_currency(line.currency_id, line.amount_currency, line.balance)
|
|
|
|
new_lines = []
|
|
has_exchange_diff = False
|
|
stop_reco_at_first_partial = self.env.context.get('stop_reco_at_first_partial')
|
|
partial_applied = False
|
|
for move_line in move_lines:
|
|
exchange_diff_balance = self._lines_get_account_balance_exchange_diff(move_line.currency_id, move_line.amount_residual, move_line.amount_residual_currency)
|
|
current_balance = -(move_line.amount_residual + exchange_diff_balance)
|
|
current_amount_currency = self._convert_amount_to_transaction_currency(move_line.currency_id, move_line.amount_residual_currency, move_line.amount_residual)
|
|
|
|
has_exchange_diff = has_exchange_diff or not move_line.currency_id.is_zero(exchange_diff_balance)
|
|
open_balance += current_balance
|
|
open_amount_currency -= current_amount_currency
|
|
|
|
new_line_balance = current_balance
|
|
new_amount_currency = -move_line.amount_residual_currency
|
|
|
|
# Partial amount will be calculated only on the last invoice of the one selected by the user unless stop_reco_at_first_partial is present in context.
|
|
if move_line == move_lines[-1] or stop_reco_at_first_partial:
|
|
partial_amounts = (
|
|
self._get_partial_amounts(current_balance, move_line, open_amount_currency, open_balance)
|
|
if (company_currency.compare_amounts(open_balance, 0) < 0 if company_currency.compare_amounts(company_amount, 0) > 0 else company_currency.compare_amounts(open_balance, 0) > 0)
|
|
else None
|
|
)
|
|
if partial_amounts and not company_currency.is_zero(partial_amounts['partial_balance']):
|
|
new_line_balance = partial_amounts['partial_balance']
|
|
new_amount_currency = partial_amounts['partial_amount_currency']
|
|
partial_applied = True
|
|
|
|
new_lines_to_add = [move_line._get_aml_values(
|
|
balance=new_line_balance,
|
|
amount_currency=new_amount_currency,
|
|
currency_id=move_line.currency_id.id,
|
|
reconcile_model_id=False,
|
|
reconciled_lines_ids=[Command.set(move_line.ids)],
|
|
)]
|
|
self.move_id._compute_checked() # to add to compute dependencies
|
|
|
|
lines_with_epd, total_amount, total_amount_currency = self._apply_early_payment_discount(
|
|
move_line,
|
|
open_amount_currency,
|
|
transaction_currency,
|
|
exchange_diff_balance,
|
|
)
|
|
if lines_with_epd:
|
|
open_balance -= total_amount + current_balance
|
|
open_amount_currency += current_amount_currency - total_amount_currency
|
|
|
|
new_lines.extend(lines_with_epd or new_lines_to_add)
|
|
if stop_reco_at_first_partial and (partial_applied or company_currency.is_zero(open_balance)):
|
|
break
|
|
|
|
self.with_context(
|
|
no_exchange_difference_no_recursive=not has_exchange_diff,
|
|
recompute_partner=True,
|
|
)._add_move_line_to_statement_line_move(new_lines)
|
|
|
|
def _get_partial_amounts(self, current_balance, move_line, open_amount_currency, open_balance):
|
|
def has_enough(currency, open_amount, current_amount):
|
|
return (
|
|
currency.compare_amounts(open_amount, 0) > 0
|
|
and currency.compare_amounts(current_amount, 0) > 0
|
|
and currency.compare_amounts(current_amount, open_amount) > 0
|
|
)
|
|
transaction_amount, transaction_currency, _journal_amount, _journal_currency, company_amount, company_currency = self._get_accounting_amounts_and_currencies()
|
|
has_enough_comp_debit = has_enough(company_currency, open_balance, current_balance)
|
|
has_enough_comp_credit = has_enough(company_currency, -open_balance, -current_balance)
|
|
current_amount_currency = -move_line.amount_residual_currency
|
|
has_enough_curr_debit = has_enough(move_line.currency_id, open_amount_currency, current_amount_currency)
|
|
has_enough_curr_credit = has_enough(move_line.currency_id, -open_amount_currency, -current_amount_currency)
|
|
|
|
tolerance = self._get_payment_tolerance()
|
|
if move_line.currency_id == transaction_currency and (has_enough_curr_debit or has_enough_curr_credit):
|
|
new_amount_currency = (
|
|
current_amount_currency
|
|
# If the open amount is small, fully reconcile the move_line and not the transaction
|
|
if not float_is_zero(tolerance, 6) and move_line.currency_id.compare_amounts(abs(open_amount_currency), tolerance * abs(current_amount_currency)) < 0
|
|
else current_amount_currency - open_amount_currency
|
|
)
|
|
rate = abs(company_amount / transaction_amount) if transaction_amount else 0.0
|
|
|
|
# Compute the amounts to make a partial.
|
|
balance_after_partial = move_line.company_currency_id.round(new_amount_currency * rate)
|
|
return {
|
|
'partial_balance': balance_after_partial,
|
|
'partial_amount_currency': new_amount_currency,
|
|
}
|
|
elif has_enough_comp_debit or has_enough_comp_credit:
|
|
# Compute the new value for balance.
|
|
balance_after_partial = (
|
|
current_balance
|
|
# If the open amount is small, fully reconcile the move_line and not the transaction
|
|
if not float_is_zero(tolerance, 6) and move_line.currency_id.compare_amounts(abs(open_balance), tolerance * abs(current_balance)) < 0
|
|
else current_balance - open_balance
|
|
)
|
|
# Get the rate of the original journal item.
|
|
rate = move_line.currency_rate
|
|
|
|
# Compute the amounts to make a partial.
|
|
new_line_balance = move_line.company_currency_id.round(balance_after_partial * abs(move_line.amount_residual) / abs(current_balance))
|
|
new_amount_currency = move_line.currency_id.round(new_line_balance * rate)
|
|
|
|
# Some amounts might have just lost their precision due to all the rounding operations.
|
|
# Assume they're the same if its raw conversion is close enough.
|
|
if company_currency.compare_amounts(new_line_balance, (-move_line.amount_residual_currency / rate)) == 0:
|
|
new_amount_currency = -move_line.amount_residual_currency
|
|
|
|
return {
|
|
'partial_balance': balance_after_partial,
|
|
'partial_amount_currency': new_amount_currency,
|
|
}
|
|
return None
|
|
|
|
def _get_payment_tolerance(self):
|
|
if self.env.context.get('skip_payment_tolerance'):
|
|
return 0
|
|
try:
|
|
payment_tolerance = float(self.env['ir.config_parameter'].sudo().get_param('account_accountant.bank_rec_payment_tolerance', 0))
|
|
# In case the payment tolerance is not a float
|
|
except ValueError:
|
|
payment_tolerance = 0
|
|
return payment_tolerance
|
|
|
|
def _lines_get_account_balance_exchange_diff(self, currency_id, amount, amount_currency):
|
|
# Compute the balance of the line using the rate/currency coming from the bank transaction.
|
|
amounts_in_st_curr = self._prepare_counterpart_amounts_using_st_line_rate(
|
|
currency_id,
|
|
amount,
|
|
amount_currency,
|
|
)
|
|
transaction_currency_id = self.foreign_currency_id or self.currency_id
|
|
origin_balance = amounts_in_st_curr['balance']
|
|
if currency_id == self.company_currency_id and transaction_currency_id != self.company_currency_id:
|
|
# The reconciliation will be expressed using the rate of the statement line.
|
|
origin_balance = amount
|
|
elif currency_id != self.company_currency_id and transaction_currency_id == self.company_currency_id:
|
|
# The reconciliation will be expressed using the foreign currency of the aml to cover the Mexican case.
|
|
origin_balance = currency_id._convert(amount_currency, transaction_currency_id, self.company_id, self.date)
|
|
|
|
# Compute the exchange difference balance.
|
|
# Useful for example when the currency has a rounding of 1 and that we have a exchange diff of 0.01, we don't want
|
|
# the exchange diff to be created.
|
|
if currency_id.is_zero(origin_balance - amount):
|
|
return 0.0
|
|
|
|
return self.company_currency_id.round(origin_balance - amount)
|
|
|
|
@api.model
|
|
def _qualifies_for_early_payment(self, transaction_currency, open_amount_currency, total_early_payment_discount):
|
|
# In the case of in_invoices, the move(_line) amounts will be negative and added to a negative statement
|
|
remaining = open_amount_currency + total_early_payment_discount
|
|
if total_early_payment_discount < 0:
|
|
return transaction_currency.compare_amounts(remaining, 0.0) <= 0
|
|
return transaction_currency.compare_amounts(remaining, 0.0) >= 0
|
|
|
|
def _set_early_payment_discount_lines(self, early_pay_aml_values_list, open_balance):
|
|
early_payment_values = self.env['account.move']._get_invoice_counterpart_amls_for_early_payment_discount(
|
|
early_pay_aml_values_list,
|
|
-open_balance,
|
|
)
|
|
new_lines = []
|
|
# We want to be able to apply an EPD at any stage in the reconciliation,
|
|
# so open_balance can be more than just the EPD amount.
|
|
# As the EPD computation method uses the remaining of the open_balance to compute the exchange diff,
|
|
# we just remove it from the information to add.
|
|
early_payment_values.pop('exchange_lines')
|
|
|
|
for vals_list in early_payment_values.values():
|
|
for vals in vals_list:
|
|
new_lines.append({
|
|
'account_id': vals['account_id'],
|
|
'date': self.date,
|
|
'name': vals['name'],
|
|
'partner_id': vals['partner_id'],
|
|
'currency_id': vals['currency_id'],
|
|
'amount_currency': vals['amount_currency'],
|
|
'balance': vals['balance'],
|
|
'analytic_distribution': vals.get('analytic_distribution'),
|
|
'tax_ids': vals.get('tax_ids', []),
|
|
'tax_tag_ids': vals.get('tax_tag_ids', []),
|
|
'tax_repartition_line_id': vals.get('tax_repartition_line_id'),
|
|
'group_tax_id': vals.get('group_tax_id'),
|
|
})
|
|
return new_lines
|
|
|
|
def delete_reconciled_line(self, move_line_ids):
|
|
""" Deletes the specified move lines from the bank statement line after unreconciling them.
|
|
|
|
:param move_line_ids: A list of move line IDs to be deleted.
|
|
"""
|
|
self.ensure_one()
|
|
if self.checked and self.is_reconciled and not self.move_id._is_user_able_to_review():
|
|
raise ValidationError(_("Validated entries can only be changed by your accountant."))
|
|
|
|
move_lines_to_remove = self.env['account.move.line'].browse(move_line_ids)
|
|
# We cannot delete a tax line
|
|
if move_lines_to_remove.tax_line_id and not move_lines_to_remove.account_id.reconcile:
|
|
return
|
|
|
|
liquidity_line, _suspense_lines, other_lines = self._seek_for_lines()
|
|
reco_model_id = move_lines_to_remove.reconcile_model_id[:1]
|
|
# Get the original base lines and tax lines before the deletion of the line
|
|
if move_lines_to_remove_has_tax := move_lines_to_remove.tax_ids:
|
|
original_base_lines, original_tax_lines = self._prepare_for_tax_lines_recomputation()
|
|
|
|
move_lines_to_remove.remove_move_reconcile()
|
|
self._set_move_line_to_statement_line_move(
|
|
liquidity_line + other_lines - move_lines_to_remove,
|
|
[],
|
|
)
|
|
self._post_matching_unreconciled()
|
|
if reco_model_id:
|
|
self._action_manual_reco_model(reco_model_id)
|
|
|
|
# Now that the line has been removed, we can recompute the taxes
|
|
if move_lines_to_remove_has_tax:
|
|
self._delete_tax_lines(original_base_lines, original_tax_lines, move_lines_to_remove)
|
|
|
|
def edit_reconcile_line(self, move_line_id, record_data):
|
|
""" Edits the specified move line from the bank statement line with the given data.
|
|
We can only edit line that are not linked to a move line.
|
|
|
|
:param move_line_id: The ID of the move line to be edited.
|
|
:param record_data: A dictionary containing the data to update the move line with.
|
|
"""
|
|
self.ensure_one()
|
|
if self.checked and self.is_reconciled and not self.move_id._is_user_able_to_review():
|
|
raise ValidationError(_("Validated entries can only be changed by your accountant."))
|
|
|
|
move_line_to_edit = self.env['account.move.line'].browse(move_line_id)
|
|
|
|
if record_data.get('account_id'):
|
|
# We remove all the analytics entries for this journal entry
|
|
move_line_to_edit.analytic_line_ids.with_context(skip_analytic_sync=True).unlink()
|
|
|
|
# Since we are doing the change in stable, some field are editable for tax line. Which shouldn't be possible.
|
|
# This solution is not perfect since depending on the record in record_data the edit will do stuff or not.
|
|
if move_line_to_edit.tax_line_id and any(record_data.get(key) for key in ['tax_ids', 'partner_id', 'account_id']):
|
|
return
|
|
|
|
# When the currency rate decrease between the account.move and the statement line date, we have a reconciled exchange_move line
|
|
# linked to the move_line_to_edit, but this will raise an error during the _check_amls_exigibility_for_reconciliation.
|
|
# We need to filter this line out as it has been reverted and reconciled, so it shouldn't interfere with the line we're trying to reconcile.
|
|
exchange_line = self.env['account.move.line']
|
|
if (exchange_move := move_line_to_edit._get_matched_move_ids().exchange_move_id) and any(record_data.get(key) for key in ['balance', 'amount_currency']):
|
|
exchange_line |= exchange_move.line_ids.filtered(lambda line: line in move_line_to_edit.reconciled_lines_ids)
|
|
|
|
liquidity_lines, _suspense_lines, other_lines = self._seek_for_lines()
|
|
|
|
original_base_lines = original_tax_lines = None
|
|
# Get the original base lines and tax lines before the edit of the line
|
|
if not move_line_to_edit.reconciled_lines_ids and any(record_data.get(key) for key in ['tax_ids', 'balance', 'amount_currency']) and not move_line_to_edit.tax_line_id and not exchange_move:
|
|
original_base_lines, original_tax_lines = self._prepare_for_tax_lines_recomputation()
|
|
|
|
edited_move_reconciled_line_ids = (move_line_to_edit.reconciled_lines_ids - exchange_line).ids
|
|
move_line_to_edit.remove_move_reconcile()
|
|
move_line_to_edit_vals = move_line_to_edit._get_aml_values(**record_data)
|
|
if edited_move_reconciled_line_ids:
|
|
move_line_to_edit_vals['reconciled_lines_ids'] = [Command.set(edited_move_reconciled_line_ids)]
|
|
|
|
self._set_move_line_to_statement_line_move(
|
|
(liquidity_lines + other_lines) - move_line_to_edit,
|
|
[move_line_to_edit_vals],
|
|
)
|
|
_new_liquidity_lines, new_suspense_lines, _new_other_lines = self._seek_for_lines()
|
|
edited_line = self.line_ids - (liquidity_lines + other_lines + new_suspense_lines)
|
|
|
|
# Now that the new line has been added, we can recompute the taxes
|
|
if not edited_line.reconciled_lines_ids and any(record_data.get(key) for key in ['tax_ids', 'balance', 'amount_currency']) and not edited_line.tax_line_id and not exchange_move:
|
|
self._edit_tax_lines(original_base_lines, original_tax_lines, edited_line, move_line_to_edit)
|
|
|
|
# Means that we tried to remove the partner
|
|
if 'partner_id' in record_data and not record_data['partner_id']:
|
|
edited_line.partner_id = False
|
|
|
|
def _prepare_for_tax_lines_recomputation(self):
|
|
liquidity_lines, _suspense_lines, other_lines = self._seek_for_lines()
|
|
other_lines = other_lines.filtered(lambda line: not line.reconciled_lines_ids) # We do not recompute tax on lines that come from invoice
|
|
|
|
base_amls = other_lines.filtered(lambda line: not line.tax_repartition_line_id)
|
|
base_lines = [self._prepare_base_line_for_taxes_computation(line) for line in base_amls]
|
|
tax_amls = other_lines - base_amls
|
|
tax_lines = [self._prepare_tax_line_for_taxes_computation(line) for line in tax_amls]
|
|
return base_lines, tax_lines
|
|
|
|
def _create_tax_lines(self, original_base_lines, original_tax_lines, new_lines):
|
|
self.ensure_one()
|
|
liquidity_lines, _suspense_lines, other_lines = self._seek_for_lines()
|
|
|
|
original_base_lines, original_tax_lines = self._recompute_tax_lines(original_base_lines, original_tax_lines)
|
|
original_base_lines += [self._prepare_base_line_for_taxes_computation(move_line) for move_line in new_lines]
|
|
|
|
self._post_recompute_tax_lines(original_base_lines, liquidity_lines, original_tax_lines, other_lines)
|
|
|
|
def _edit_tax_lines(self, original_base_lines, original_tax_lines, edited_line, old_move_line):
|
|
self.ensure_one()
|
|
liquidity_lines, _suspense_lines, other_lines = self._seek_for_lines()
|
|
|
|
original_base_lines, original_tax_lines = self._recompute_tax_lines(original_base_lines, original_tax_lines)
|
|
|
|
# In the context of an edit the move_lines parameter contains only one line
|
|
edit_base_line = self._prepare_base_line_for_taxes_computation(edited_line)
|
|
base_lines = []
|
|
for base_line in original_base_lines:
|
|
original_price_unit = base_line['price_unit']
|
|
base_line['price_unit'] += sum(
|
|
tax_data['tax_amount_currency']
|
|
for tax_data in base_line['tax_details']['taxes_data']
|
|
)
|
|
if base_line['record'] != old_move_line:
|
|
base_lines.append(base_line)
|
|
continue
|
|
|
|
price_unit_modified = self.currency_id.compare_amounts(edit_base_line['price_unit'], original_price_unit) != 0
|
|
if base_line['tax_ids'] and not price_unit_modified:
|
|
edit_base_line['price_unit'] = base_line['price_unit']
|
|
|
|
base_lines.append(edit_base_line)
|
|
|
|
self._post_recompute_tax_lines(base_lines, liquidity_lines, original_tax_lines, other_lines)
|
|
|
|
def _delete_tax_lines(self, original_base_lines, original_tax_lines, move_line_to_remove):
|
|
self.ensure_one()
|
|
liquidity_lines, _suspense_lines, other_lines = self._seek_for_lines()
|
|
|
|
original_base_lines, original_tax_lines = self._recompute_tax_lines(original_base_lines, original_tax_lines)
|
|
base_lines = []
|
|
for base_line in original_base_lines:
|
|
if base_line['record'] == move_line_to_remove:
|
|
continue
|
|
|
|
base_line['price_unit'] += sum(
|
|
tax_data['tax_amount_currency']
|
|
for tax_data in base_line['tax_details']['taxes_data']
|
|
)
|
|
base_lines.append(base_line)
|
|
|
|
self._post_recompute_tax_lines(base_lines, liquidity_lines, original_tax_lines, other_lines)
|
|
|
|
def _recompute_tax_lines(self, original_base_lines=None, original_tax_lines=None):
|
|
self.ensure_one()
|
|
# To be stable friendly we had a fallback
|
|
if original_base_lines is None and original_tax_lines is None:
|
|
original_base_lines, original_tax_lines = self._prepare_for_tax_lines_recomputation()
|
|
|
|
AccountTax = self.env['account.tax']
|
|
# Restore original price unit.
|
|
AccountTax._add_tax_details_in_base_lines(original_base_lines, self.company_id)
|
|
AccountTax._round_base_lines_tax_details(original_base_lines, self.company_id, tax_lines=original_tax_lines)
|
|
|
|
return original_base_lines, original_tax_lines
|
|
|
|
def _post_recompute_tax_lines(self, base_lines, liquidity_lines, original_tax_lines, other_lines):
|
|
self.ensure_one()
|
|
AccountTax = self.env['account.tax']
|
|
AccountTax._add_tax_details_in_base_lines(base_lines, self.company_id)
|
|
AccountTax._round_base_lines_tax_details(base_lines, self.company_id)
|
|
AccountTax._add_accounting_data_in_base_lines_tax_details(base_lines, self.company_id, include_caba_tags=True)
|
|
tax_results = AccountTax._prepare_tax_lines(base_lines, self.company_id, tax_lines=original_tax_lines)
|
|
|
|
lines_to_delete = self.env['account.move.line']
|
|
lines_to_add_or_update = []
|
|
|
|
# Update the base lines.
|
|
for base_line, to_update in tax_results['base_lines_to_update']:
|
|
line = base_line['record']
|
|
line_values = {
|
|
'amount_currency': to_update['amount_currency'],
|
|
'balance': self._prepare_counterpart_amounts_using_st_line_rate(line.currency_id, line.amount_residual or line.balance, to_update['amount_currency'])['balance'],
|
|
'tax_tag_ids': to_update['tax_tag_ids'],
|
|
}
|
|
if line.reconciled_lines_ids:
|
|
line_values['reconciled_lines_ids'] = [Command.set(line.reconciled_lines_ids.ids)]
|
|
lines_to_delete += line
|
|
lines_to_add_or_update.append(line._get_aml_values(**line_values))
|
|
|
|
# Tax lines that are no longer needed.
|
|
for tax_line_vals in tax_results['tax_lines_to_delete']:
|
|
lines_to_delete += tax_line_vals['record']
|
|
|
|
# Newly created tax lines.
|
|
for tax_line_vals in tax_results['tax_lines_to_add']:
|
|
lines_to_add_or_update.append(self._lines_prepare_tax_line(tax_line_vals))
|
|
|
|
# Update of existing tax lines.
|
|
for tax_line_vals, grouping_key, to_update in tax_results['tax_lines_to_update']:
|
|
lines_to_delete += tax_line_vals['record']
|
|
new_line_vals = self._lines_prepare_tax_line({**grouping_key, **to_update})
|
|
lines_to_add_or_update.append(tax_line_vals['record']._get_aml_values(**new_line_vals))
|
|
|
|
lines_to_keep = (liquidity_lines + other_lines) - lines_to_delete
|
|
self._set_move_line_to_statement_line_move(lines_to_keep, lines_to_add_or_update)
|
|
|
|
def _lines_prepare_tax_line(self, tax_line_vals):
|
|
self.ensure_one()
|
|
|
|
tax_rep = self.env['account.tax.repartition.line'].browse(tax_line_vals['tax_repartition_line_id'])
|
|
name = tax_rep.tax_id.name
|
|
if self.payment_ref:
|
|
name = f'{name} - {self.payment_ref}'
|
|
currency = self.env['res.currency'].browse(tax_line_vals['currency_id'])
|
|
amount_currency = tax_line_vals['amount_currency']
|
|
balance = self._prepare_counterpart_amounts_using_st_line_rate(currency, None, amount_currency)['balance']
|
|
|
|
return {
|
|
'account_id': tax_line_vals['account_id'],
|
|
'date': self.date,
|
|
'name': name,
|
|
'partner_id': tax_line_vals['partner_id'],
|
|
'currency_id': currency.id,
|
|
'amount_currency': amount_currency,
|
|
'balance': balance,
|
|
'analytic_distribution': tax_line_vals['analytic_distribution'],
|
|
'tax_repartition_line_id': tax_rep.id,
|
|
'tax_ids': tax_line_vals['tax_ids'],
|
|
'tax_tag_ids': tax_line_vals['tax_tag_ids'],
|
|
'group_tax_id': tax_line_vals['group_tax_id'],
|
|
'tax_base_amount': tax_line_vals['tax_base_amount'],
|
|
}
|
|
|
|
def _prepare_base_line_for_taxes_computation(self, line_vals):
|
|
""" Convert the current dictionary to use the generic taxes computation method defined on account.tax.
|
|
|
|
:returns: A dictionary representing a base line.
|
|
"""
|
|
self.ensure_one()
|
|
if not line_vals:
|
|
return {}
|
|
tax_type = line_vals.tax_ids.mapped('type_tax_use')
|
|
if 'sale' in tax_type and 'purchase' in tax_type:
|
|
# When we have sale and purchase tax on the same line, we take the sale type as default
|
|
tax_type = 'sale'
|
|
else:
|
|
tax_type = line_vals.tax_ids[0].type_tax_use if line_vals.tax_ids else None
|
|
is_refund = (tax_type == 'sale' and line_vals.balance > 0.0) or (tax_type == 'purchase' and line_vals.balance < 0.0)
|
|
|
|
return self.env['account.tax']._prepare_base_line_for_taxes_computation(
|
|
line_vals,
|
|
price_unit=line_vals.amount_currency,
|
|
quantity=1.0,
|
|
is_refund=is_refund,
|
|
special_mode='total_included',
|
|
)
|
|
|
|
def _prepare_tax_line_for_taxes_computation(self, line):
|
|
""" Convert the current dictionary to use the generic taxes computation method defined on account.tax.
|
|
|
|
:return: A dictionary representing a tax line.
|
|
"""
|
|
self.ensure_one()
|
|
if not line:
|
|
return {}
|
|
return self.env['account.tax']._prepare_tax_line_for_taxes_computation(line)
|
|
|
|
def _reconcile_payments(self, payments, amls_to_create, reconciled_lines=None):
|
|
self.ensure_one()
|
|
has_exchange_diff = False
|
|
if reconciled_lines:
|
|
for reconciled_line, aml_to_create in zip(reconciled_lines, amls_to_create):
|
|
exchange_diff_balance = self._lines_get_account_balance_exchange_diff(reconciled_line.currency_id, reconciled_line.amount_residual, reconciled_line.amount_residual_currency)
|
|
has_exchange_diff = has_exchange_diff or not reconciled_line.currency_id.is_zero(exchange_diff_balance)
|
|
new_balance = -(reconciled_line.amount_residual + exchange_diff_balance)
|
|
|
|
aml_to_create['balance'] = new_balance
|
|
|
|
self.with_context(no_exchange_difference_no_recursive=not has_exchange_diff)._add_move_line_to_statement_line_move(amls_to_create)
|
|
if payments_to_validate := payments.filtered(lambda p: not p.move_id and p.state in self.env['account.batch.payment']._valid_payment_states()):
|
|
payments_to_validate.action_validate()
|
|
|
|
def create_document_from_attachment(self, attachment_ids):
|
|
""" Create the invoices from files.
|
|
:return: A action redirecting to account.move list/form view.
|
|
"""
|
|
statement_line = self.browse(self.env.context.get("statement_line_id"))
|
|
|
|
purchase_journal_id = self.env['account.journal'].search_fetch(
|
|
domain=[*self.env['account.journal']._check_company_domain(statement_line.company_id), ('type', '=', 'purchase')],
|
|
field_names=['id'],
|
|
limit=1,
|
|
)
|
|
invoices = purchase_journal_id.with_context(default_move_type="in_invoice")._create_document_from_attachment(attachment_ids)
|
|
# This will work only for xml file, since when it's a pdf the ocr has not yet digitize the document and we have an empty move
|
|
if lines := invoices.line_ids.filtered(lambda l: l.account_id.account_type in {'asset_receivable', 'liability_payable'}):
|
|
statement_line.set_line_bank_statement_line(lines.ids)
|
|
invoices.action_activate_currency()
|
|
|
|
return invoices._get_records_action()
|
|
|
|
def action_unreconcile_entry(self):
|
|
self.ensure_one()
|
|
|
|
_liquidity_lines, _suspense_lines, other_lines = self._seek_for_lines()
|
|
other_lines.remove_move_reconcile()
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
statement_lines = super().create(vals_list)
|
|
if not self.env.context.get('no_retrieve_partner'):
|
|
statement_lines._retrieve_partner()
|
|
statement_lines.move_id._message_log_batch(bodies={
|
|
statement_line.move_id.id: statement_line._format_statement_line_data()
|
|
for statement_line in statement_lines
|
|
})
|
|
|
|
# process automatically the new lines in case we pass some context key (i.e coming from the bank reconciliation widget)
|
|
if self.env.context.get('auto_statement_processing', False) and statement_lines:
|
|
if len(statement_lines) <= AUTO_STATEMENT_PROCESSING_NUMBER:
|
|
statement_lines._try_auto_reconcile_statement_lines()
|
|
else:
|
|
statement_lines._cron_try_auto_reconcile_statement_lines(batch_size=AUTO_STATEMENT_PROCESSING_BATCH_SIZE)
|
|
return statement_lines
|
|
|
|
@api.deprecated("Use _format_statement_line_data instead")
|
|
def _format_transaction_details(self):
|
|
return self._format_statement_line_data()
|
|
|
|
def _format_statement_line_data(self):
|
|
def _get_formatted_transaction_details(data, prefix=""):
|
|
keys = data.keys() if isinstance(data, dict) else [i for i, _ in enumerate(data)]
|
|
result = Markup()
|
|
for key in keys:
|
|
value = data[key]
|
|
result += prefix + Markup("<b>%s:</b> ") % str(key)
|
|
if isinstance(value, (list, dict)):
|
|
result += "\n"
|
|
result += _get_formatted_transaction_details(value, prefix + " ")
|
|
continue
|
|
result += str(value) + "\n"
|
|
return result
|
|
|
|
def _get_formatted_statement_line_data():
|
|
result = Markup()
|
|
if self.partner_name or self.partner_id or self.account_number:
|
|
result += Markup('<h4 class="d-inline">{name}</h4> <span class="text-secondary">―</span> ').format(
|
|
name=self.partner_name or self.partner_id.name or self.account_number,
|
|
)
|
|
result += Markup('<h4 class="d-inline text-info">{amount}</h4><br/>').format(amount=self.currency_id.format(self.amount))
|
|
if self.account_number and (self.partner_name or self.partner_id):
|
|
# Make sure to include the account number if we didn't use it instead of the partner name
|
|
result += Markup('<b>{account_number}</b> <span class="text-secondary">-</span> ').format(account_number=self.account_number)
|
|
result += Markup('{date}<br/>').format(date=format_date(self.env, self.date, date_format='dd MMM yyyy'))
|
|
result += Markup('<br/>{remittance_information}<br/>').format(remittance_information=self.payment_ref)
|
|
return result
|
|
|
|
self.ensure_one()
|
|
|
|
formatted_statement_line_data = _get_formatted_statement_line_data()
|
|
if self.transaction_details:
|
|
formatted_statement_line_data = Markup(
|
|
'%s<div data-o-mail-quote="1"><pre>%s</pre></div>'
|
|
) % (
|
|
formatted_statement_line_data,
|
|
_get_formatted_transaction_details(self.transaction_details),
|
|
)
|
|
return formatted_statement_line_data
|