When the name of a partner contains a regular expression operator, such
as grouping parenthesis, +, *, brackets... the resulting regexp may
match wrong names or worse be invalid and lead to an exception.
With this commit, we create a regular expression from only the words
formed from at least 3 of [a-zA-Z0-9], ignoring all other characters,
including those who may have a meaning in a regular expression.
This avoids these issues:
- regexp injection (security issue)
- punctuation resulting in erroneous regular expression ("ABC + SPRL"
resulted in "(?=.*ABC.*)(?=.*+.*)(?=.*SPRL.*)" which is invalid at
the ".*+" part)
- false positives due to words that are too small (\b were added, and
the minimal matching word size is 3)
- accentuation mismatch (both sides are filtered through unaccent)
- special cases in unicode that have a special behavior with lower().
See the discussion on odoo/odoo#63145 for a detailled explaination about
these issues.
OPW-2360687
closes odoo/odoo#64510
X-original-commit: 3c5296863f0030acc5d3dba0ff73a6f1b42d1255
Signed-off-by: William André (wan) <wan@odoo.com>
Signed-off-by: Paul Morelle <madprog@users.noreply.github.com>
960 lines
48 KiB
Python
960 lines
48 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.tools import float_compare, float_is_zero
|
|
from odoo.osv.expression import get_unaccent_wrapper
|
|
from odoo.exceptions import UserError, ValidationError
|
|
import re
|
|
from math import copysign
|
|
import itertools
|
|
from collections import defaultdict
|
|
from dateutil.relativedelta import relativedelta
|
|
|
|
class AccountReconcileModelPartnerMapping(models.Model):
|
|
_name = 'account.reconcile.model.partner.mapping'
|
|
_description = 'Partner mapping for reconciliation models'
|
|
|
|
model_id = fields.Many2one(comodel_name='account.reconcile.model', readonly=True, required=True, ondelete='cascade')
|
|
partner_id = fields.Many2one(comodel_name='res.partner', string="Partner", required=True, ondelete='cascade')
|
|
payment_ref_regex = fields.Char(string="Find Text in Label")
|
|
narration_regex = fields.Char(string="Find Text in Notes")
|
|
|
|
@api.constrains('narration_regex', 'payment_ref_regex')
|
|
def validate_regex(self):
|
|
for record in self:
|
|
if not (record.narration_regex or record.payment_ref_regex):
|
|
raise ValidationError(_("Please set at least one of the match texts to create a partner mapping."))
|
|
|
|
|
|
class AccountReconcileModelLine(models.Model):
|
|
_name = 'account.reconcile.model.line'
|
|
_description = 'Rules for the reconciliation model'
|
|
_order = 'sequence, id'
|
|
_check_company_auto = True
|
|
|
|
model_id = fields.Many2one('account.reconcile.model', readonly=True, ondelete='cascade')
|
|
match_total_amount = fields.Boolean(related='model_id.match_total_amount')
|
|
match_total_amount_param = fields.Float(related='model_id.match_total_amount_param')
|
|
rule_type = fields.Selection(related='model_id.rule_type')
|
|
company_id = fields.Many2one(related='model_id.company_id', store=True, default=lambda self: self.env.company)
|
|
sequence = fields.Integer(required=True, default=10)
|
|
account_id = fields.Many2one('account.account', string='Account', ondelete='cascade',
|
|
domain="[('deprecated', '=', False), ('company_id', '=', company_id), ('is_off_balance', '=', False)]",
|
|
required=True, check_company=True)
|
|
journal_id = fields.Many2one('account.journal', string='Journal', ondelete='cascade',
|
|
domain="[('type', '=', 'general'), ('company_id', '=', company_id)]",
|
|
help="This field is ignored in a bank statement reconciliation.", check_company=True)
|
|
label = fields.Char(string='Journal Item Label')
|
|
amount_type = fields.Selection([
|
|
('fixed', 'Fixed'),
|
|
('percentage', 'Percentage of balance'),
|
|
('regex', 'From label'),
|
|
], required=True, default='percentage')
|
|
show_force_tax_included = fields.Boolean(compute='_compute_show_force_tax_included', help='Technical field used to show the force tax included button')
|
|
force_tax_included = fields.Boolean(string='Tax Included in Price', help='Force the tax to be managed as a price included tax.')
|
|
amount = fields.Float(string="Float Amount", compute='_compute_float_amount', store=True, help="Technical shortcut to parse the amount to a float")
|
|
amount_string = fields.Char(string="Amount", default='100', required=True, help="""Value for the amount of the writeoff line
|
|
* Percentage: Percentage of the balance, between 0 and 100.
|
|
* Fixed: The fixed value of the writeoff. The amount will count as a debit if it is negative, as a credit if it is positive.
|
|
* From Label: There is no need for regex delimiter, only the regex is needed. For instance if you want to extract the amount from\nR:9672938 10/07 AX 9415126318 T:5L:NA BRT: 3358,07 C:\nYou could enter\nBRT: ([\d,]+)""")
|
|
tax_ids = fields.Many2many('account.tax', string='Taxes', ondelete='restrict', check_company=True)
|
|
analytic_account_id = fields.Many2one('account.analytic.account', string='Analytic Account', ondelete='set null', check_company=True)
|
|
analytic_tag_ids = fields.Many2many('account.analytic.tag', string='Analytic Tags', check_company=True,
|
|
relation='account_reconcile_model_analytic_tag_rel')
|
|
|
|
@api.onchange('tax_ids')
|
|
def _onchange_tax_ids(self):
|
|
# Multiple taxes with force_tax_included results in wrong computation, so we
|
|
# only allow to set the force_tax_included field if we have one tax selected
|
|
if len(self.tax_ids) != 1:
|
|
self.force_tax_included = False
|
|
|
|
@api.depends('tax_ids')
|
|
def _compute_show_force_tax_included(self):
|
|
for record in self:
|
|
record.show_force_tax_included = False if len(record.tax_ids) != 1 else True
|
|
|
|
@api.onchange('amount_type')
|
|
def _onchange_amount_type(self):
|
|
self.amount_string = ''
|
|
if self.amount_type == 'percentage':
|
|
self.amount_string = '100'
|
|
elif self.amount_type == 'regex':
|
|
self.amount_string = '([\d,]+)'
|
|
|
|
@api.depends('amount_string')
|
|
def _compute_float_amount(self):
|
|
for record in self:
|
|
try:
|
|
record.amount = float(record.amount_string)
|
|
except ValueError:
|
|
record.amount = 0
|
|
|
|
@api.constrains('amount_string')
|
|
def _validate_amount(self):
|
|
for record in self:
|
|
if record.amount_type == 'fixed' and record.amount == 0:
|
|
raise UserError(_('The amount is not a number'))
|
|
if record.amount_type == 'percentage' and not 0 < record.amount:
|
|
raise UserError(_('The amount is not a percentage'))
|
|
if record.amount_type == 'regex':
|
|
try:
|
|
re.compile(record.amount_string)
|
|
except re.error:
|
|
raise UserError(_('The regex is not valid'))
|
|
|
|
|
|
class AccountReconcileModel(models.Model):
|
|
_name = 'account.reconcile.model'
|
|
_description = 'Preset to create journal entries during a invoices and payments matching'
|
|
_order = 'sequence, id'
|
|
_check_company_auto = True
|
|
|
|
# Base fields.
|
|
active = fields.Boolean(default=True)
|
|
name = fields.Char(string='Name', required=True)
|
|
sequence = fields.Integer(required=True, default=10)
|
|
company_id = fields.Many2one(
|
|
comodel_name='res.company',
|
|
string='Company', required=True, readonly=True,
|
|
default=lambda self: self.env.company)
|
|
|
|
rule_type = fields.Selection(selection=[
|
|
('writeoff_button', 'Manually create a write-off on clicked button'),
|
|
('writeoff_suggestion', 'Suggest counterpart values'),
|
|
('invoice_matching', 'Match existing invoices/bills'),
|
|
], string='Type', default='writeoff_button', required=True)
|
|
auto_reconcile = fields.Boolean(string='Auto-validate',
|
|
help='Validate the statement line automatically (reconciliation based on your rule).')
|
|
to_check = fields.Boolean(string='To Check', default=False, help='This matching rule is used when the user is not certain of all the information of the counterpart.')
|
|
matching_order = fields.Selection(
|
|
selection=[
|
|
('old_first', 'Oldest first'),
|
|
('new_first', 'Newest first'),
|
|
],
|
|
required=True,
|
|
default='old_first',
|
|
)
|
|
|
|
# ===== Conditions =====
|
|
match_text_location_label = fields.Boolean(
|
|
default=True,
|
|
help="Search in the Statement's Label to find the Invoice/Payment's reference",
|
|
)
|
|
match_text_location_note = fields.Boolean(
|
|
default=False,
|
|
help="Search in the Statement's Note to find the Invoice/Payment's reference",
|
|
)
|
|
match_text_location_reference = fields.Boolean(
|
|
default=False,
|
|
help="Search in the Statement's Reference to find the Invoice/Payment's reference",
|
|
)
|
|
match_journal_ids = fields.Many2many('account.journal', string='Journals',
|
|
domain="[('type', 'in', ('bank', 'cash')), ('company_id', '=', company_id)]",
|
|
check_company=True,
|
|
help='The reconciliation model will only be available from the selected journals.')
|
|
match_nature = fields.Selection(selection=[
|
|
('amount_received', 'Amount Received'),
|
|
('amount_paid', 'Amount Paid'),
|
|
('both', 'Amount Paid/Received')
|
|
], string='Amount Nature', required=True, default='both',
|
|
help='''The reconciliation model will only be applied to the selected transaction type:
|
|
* Amount Received: Only applied when receiving an amount.
|
|
* Amount Paid: Only applied when paying an amount.
|
|
* Amount Paid/Received: Applied in both cases.''')
|
|
match_amount = fields.Selection(selection=[
|
|
('lower', 'Is Lower Than'),
|
|
('greater', 'Is Greater Than'),
|
|
('between', 'Is Between'),
|
|
], string='Amount',
|
|
help='The reconciliation model will only be applied when the amount being lower than, greater than or between specified amount(s).')
|
|
match_amount_min = fields.Float(string='Amount Min Parameter')
|
|
match_amount_max = fields.Float(string='Amount Max Parameter')
|
|
match_label = fields.Selection(selection=[
|
|
('contains', 'Contains'),
|
|
('not_contains', 'Not Contains'),
|
|
('match_regex', 'Match Regex'),
|
|
], string='Label', help='''The reconciliation model will only be applied when the label:
|
|
* Contains: The proposition label must contains this string (case insensitive).
|
|
* Not Contains: Negation of "Contains".
|
|
* Match Regex: Define your own regular expression.''')
|
|
match_label_param = fields.Char(string='Label Parameter')
|
|
match_note = fields.Selection(selection=[
|
|
('contains', 'Contains'),
|
|
('not_contains', 'Not Contains'),
|
|
('match_regex', 'Match Regex'),
|
|
], string='Note', help='''The reconciliation model will only be applied when the note:
|
|
* Contains: The proposition note must contains this string (case insensitive).
|
|
* Not Contains: Negation of "Contains".
|
|
* Match Regex: Define your own regular expression.''')
|
|
match_note_param = fields.Char(string='Note Parameter')
|
|
match_transaction_type = fields.Selection(selection=[
|
|
('contains', 'Contains'),
|
|
('not_contains', 'Not Contains'),
|
|
('match_regex', 'Match Regex'),
|
|
], string='Transaction Type', help='''The reconciliation model will only be applied when the transaction type:
|
|
* Contains: The proposition transaction type must contains this string (case insensitive).
|
|
* Not Contains: Negation of "Contains".
|
|
* Match Regex: Define your own regular expression.''')
|
|
match_transaction_type_param = fields.Char(string='Transaction Type Parameter')
|
|
match_same_currency = fields.Boolean(string='Same Currency Matching', default=True,
|
|
help='Restrict to propositions having the same currency as the statement line.')
|
|
match_total_amount = fields.Boolean(string='Amount Matching', default=True,
|
|
help='The sum of total residual amount propositions matches the statement line amount.')
|
|
match_total_amount_param = fields.Float(string='Amount Matching %', default=100,
|
|
help='The sum of total residual amount propositions matches the statement line amount under this percentage.')
|
|
match_partner = fields.Boolean(string='Partner Is Set',
|
|
help='The reconciliation model will only be applied when a customer/vendor is set.')
|
|
match_partner_ids = fields.Many2many('res.partner', string='Restrict Partners to',
|
|
help='The reconciliation model will only be applied to the selected customers/vendors.')
|
|
match_partner_category_ids = fields.Many2many('res.partner.category', string='Restrict Partner Categories to',
|
|
help='The reconciliation model will only be applied to the selected customer/vendor categories.')
|
|
|
|
line_ids = fields.One2many('account.reconcile.model.line', 'model_id')
|
|
partner_mapping_line_ids = fields.One2many(string="Partner Mapping Lines",
|
|
comodel_name='account.reconcile.model.partner.mapping',
|
|
inverse_name='model_id',
|
|
help="The mapping uses regular expressions.\n"
|
|
"- To Match the text at the beginning of the line (in label or notes), simply fill in your text.\n"
|
|
"- To Match the text anywhere (in label or notes), put your text between .*\n"
|
|
" e.g: .*N°48748 abc123.*")
|
|
|
|
past_months_limit = fields.Integer(string="Past Months Limit", default=18, help="Number of months in the past to consider entries from when applying this model.")
|
|
|
|
decimal_separator = fields.Char(default=lambda self: self.env['res.lang']._lang_get(self.env.user.lang).decimal_point, help="Every character that is nor a digit nor this separator will be removed from the matching string")
|
|
show_decimal_separator = fields.Boolean(compute='_compute_show_decimal_separator', help="Technical field to decide if we should show the decimal separator for the regex matching field.")
|
|
number_entries = fields.Integer(string='Number of entries related to this model', compute='_compute_number_entries')
|
|
|
|
def action_reconcile_stat(self):
|
|
self.ensure_one()
|
|
action = self.env["ir.actions.actions"]._for_xml_id("account.action_move_journal_line")
|
|
self._cr.execute('''
|
|
SELECT ARRAY_AGG(DISTINCT move_id)
|
|
FROM account_move_line
|
|
WHERE reconcile_model_id = %s
|
|
''', [self.id])
|
|
action.update({
|
|
'context': {},
|
|
'domain': [('id', 'in', self._cr.fetchone()[0])],
|
|
'help': """<p class="o_view_nocontent_empty_folder">{}</p>""".format(_('This reconciliation model has created no entry so far')),
|
|
})
|
|
return action
|
|
|
|
def _compute_number_entries(self):
|
|
data = self.env['account.move.line'].read_group([('reconcile_model_id', 'in', self.ids)], ['reconcile_model_id'], 'reconcile_model_id')
|
|
mapped_data = dict([(d['reconcile_model_id'][0], d['reconcile_model_id_count']) for d in data])
|
|
for model in self:
|
|
model.number_entries = mapped_data.get(model.id, 0)
|
|
|
|
@api.depends('line_ids.amount_type')
|
|
def _compute_show_decimal_separator(self):
|
|
for record in self:
|
|
record.show_decimal_separator = any(l.amount_type == 'regex' for l in record.line_ids)
|
|
|
|
@api.onchange('match_total_amount_param')
|
|
def _onchange_match_total_amount_param(self):
|
|
if self.match_total_amount_param < 0 or self.match_total_amount_param > 100:
|
|
self.match_total_amount_param = min(max(0, self.match_total_amount_param), 100)
|
|
|
|
####################################################
|
|
# RECONCILIATION PROCESS
|
|
####################################################
|
|
|
|
def _get_taxes_move_lines_dict(self, tax, base_line_dict):
|
|
''' Get move.lines dict (to be passed to the create()) corresponding to a tax.
|
|
:param tax: An account.tax record.
|
|
:param base_line_dict: A dict representing the move.line containing the base amount.
|
|
:return: A list of dict representing move.lines to be created corresponding to the tax.
|
|
'''
|
|
self.ensure_one()
|
|
balance = base_line_dict['balance']
|
|
|
|
res = tax.compute_all(balance)
|
|
|
|
new_aml_dicts = []
|
|
for tax_res in res['taxes']:
|
|
tax = self.env['account.tax'].browse(tax_res['id'])
|
|
balance = tax_res['amount']
|
|
|
|
new_aml_dicts.append({
|
|
'account_id': tax_res['account_id'] or base_line_dict['account_id'],
|
|
'name': tax_res['name'],
|
|
'partner_id': base_line_dict.get('partner_id'),
|
|
'balance': balance,
|
|
'debit': balance > 0 and balance or 0,
|
|
'credit': balance < 0 and -balance or 0,
|
|
'analytic_account_id': tax.analytic and base_line_dict['analytic_account_id'],
|
|
'analytic_tag_ids': tax.analytic and base_line_dict['analytic_tag_ids'],
|
|
'tax_exigible': tax_res['tax_exigibility'],
|
|
'tax_repartition_line_id': tax_res['tax_repartition_line_id'],
|
|
'tax_ids': [(6, 0, tax_res['tax_ids'])],
|
|
'tax_tag_ids': [(6, 0, tax_res['tag_ids'])],
|
|
'currency_id': False,
|
|
'reconcile_model_id': self.id,
|
|
})
|
|
|
|
# Handle price included taxes.
|
|
base_balance = tax_res['base']
|
|
base_line_dict.update({
|
|
'balance': base_balance,
|
|
'debit': base_balance > 0 and base_balance or 0,
|
|
'credit': base_balance < 0 and -base_balance or 0,
|
|
})
|
|
|
|
base_line_dict['tax_tag_ids'] = [(6, 0, res['base_tags'])]
|
|
return new_aml_dicts
|
|
|
|
def _get_write_off_move_lines_dict(self, st_line, residual_balance):
|
|
''' Get move.lines dict (to be passed to the create()) corresponding to the reconciliation model's write-off lines.
|
|
:param st_line: An account.bank.statement.line record.(possibly empty, if performing manual reconciliation)
|
|
:param residual_balance: The residual balance of the statement line.
|
|
:return: A list of dict representing move.lines to be created corresponding to the write-off lines.
|
|
'''
|
|
self.ensure_one()
|
|
|
|
if self.rule_type == 'invoice_matching' and (not self.match_total_amount or (self.match_total_amount_param == 100)):
|
|
return []
|
|
|
|
lines_vals_list = []
|
|
|
|
for line in self.line_ids:
|
|
currency_id = st_line.currency_id or st_line.journal_id.currency_id or self.company_id.currency_id
|
|
if not line.account_id or currency_id.is_zero(residual_balance):
|
|
return []
|
|
|
|
if line.amount_type == 'percentage':
|
|
balance = residual_balance * (line.amount / 100.0)
|
|
elif line.amount_type == "regex":
|
|
match = re.search(line.amount_string, st_line.payment_ref)
|
|
if match:
|
|
sign = 1 if residual_balance > 0.0 else -1
|
|
extracted_balance = float(re.sub(r'\D' + self.decimal_separator, '', match.group(1)).replace(self.decimal_separator, '.'))
|
|
balance = copysign(extracted_balance * sign, residual_balance)
|
|
else:
|
|
balance = 0
|
|
else:
|
|
balance = line.amount * (1 if residual_balance > 0.0 else -1)
|
|
|
|
writeoff_line = {
|
|
'name': line.label or st_line.payment_ref,
|
|
'balance': balance,
|
|
'debit': balance > 0 and balance or 0,
|
|
'credit': balance < 0 and -balance or 0,
|
|
'account_id': line.account_id.id,
|
|
'currency_id': False,
|
|
'analytic_account_id': line.analytic_account_id.id,
|
|
'analytic_tag_ids': [(6, 0, line.analytic_tag_ids.ids)],
|
|
'reconcile_model_id': self.id,
|
|
}
|
|
lines_vals_list.append(writeoff_line)
|
|
|
|
residual_balance -= balance
|
|
|
|
if line.tax_ids:
|
|
writeoff_line['tax_ids'] = [(6, None, line.tax_ids.ids)]
|
|
tax = line.tax_ids
|
|
# Multiple taxes with force_tax_included results in wrong computation, so we
|
|
# only allow to set the force_tax_included field if we have one tax selected
|
|
if line.force_tax_included:
|
|
tax = tax[0].with_context(force_price_include=True)
|
|
tax_vals_list = self._get_taxes_move_lines_dict(tax, writeoff_line)
|
|
lines_vals_list += tax_vals_list
|
|
if not line.force_tax_included:
|
|
for tax_line in tax_vals_list:
|
|
residual_balance -= tax_line['balance']
|
|
|
|
return lines_vals_list
|
|
|
|
def _prepare_reconciliation(self, st_line, aml_ids=[], partner=None):
|
|
''' Prepare the reconciliation of the statement line with some counterpart line but
|
|
also with some auto-generated write-off lines.
|
|
|
|
The complexity of this method comes from the fact the reconciliation will be soft meaning
|
|
it will be done only if the reconciliation will not trigger an error.
|
|
For example, the reconciliation will be skipped if we need to create an open balance but we
|
|
don't have a partner to get the receivable/payable account.
|
|
|
|
This method works in two major steps. First, simulate the reconciliation of the account.move.line.
|
|
Then, add some write-off lines depending the rule's fields.
|
|
|
|
:param st_line: An account.bank.statement.line record.
|
|
:param aml_ids: The ids of some account.move.line to reconcile.
|
|
:param partner: An optional res.partner record. If not specified, fallback on the statement line's partner.
|
|
:return: A list of dictionary to be passed to the account.bank.statement.line's 'reconcile' method.
|
|
'''
|
|
self.ensure_one()
|
|
liquidity_lines, suspense_lines, other_lines = st_line._seek_for_lines()
|
|
|
|
if st_line.to_check:
|
|
st_line_residual = -liquidity_lines.balance
|
|
elif suspense_lines.account_id.reconcile:
|
|
st_line_residual = sum(suspense_lines.mapped('amount_residual'))
|
|
else:
|
|
st_line_residual = sum(suspense_lines.mapped('balance'))
|
|
|
|
partner = partner or st_line.partner_id
|
|
|
|
has_full_write_off= any(rec_mod_line.amount == 100.0 for rec_mod_line in self.line_ids)
|
|
|
|
lines_vals_list = []
|
|
amls = self.env['account.move.line'].browse(aml_ids)
|
|
st_line_residual_before = st_line_residual
|
|
aml_total_residual = 0
|
|
for aml in amls:
|
|
aml_total_residual += aml.amount_residual
|
|
|
|
if aml.balance * st_line_residual > 0:
|
|
# Meaning they have the same signs, so they can't be reconciled together
|
|
assigned_balance = -aml.amount_residual
|
|
elif has_full_write_off:
|
|
assigned_balance = -aml.amount_residual
|
|
st_line_residual -= min(-aml.amount_residual, st_line_residual, key=abs)
|
|
else:
|
|
assigned_balance = min(-aml.amount_residual, st_line_residual, key=abs)
|
|
st_line_residual -= assigned_balance
|
|
|
|
lines_vals_list.append({
|
|
'id': aml.id,
|
|
'balance': assigned_balance,
|
|
'currency_id': st_line.move_id.company_id.currency_id.id,
|
|
})
|
|
|
|
write_off_amount = max(aml_total_residual, -st_line_residual_before, key=abs) + st_line_residual_before + st_line_residual
|
|
|
|
reconciliation_overview, open_balance_vals = st_line._prepare_reconciliation(lines_vals_list)
|
|
|
|
writeoff_vals_list = self._get_write_off_move_lines_dict(st_line, write_off_amount)
|
|
|
|
for line_vals in writeoff_vals_list:
|
|
st_line_residual -= st_line.company_currency_id.round(line_vals['balance'])
|
|
|
|
# Check we have enough information to create an open balance.
|
|
if open_balance_vals and not open_balance_vals.get('account_id'):
|
|
return []
|
|
|
|
return lines_vals_list + writeoff_vals_list
|
|
|
|
####################################################
|
|
# RECONCILIATION CRITERIA
|
|
####################################################
|
|
|
|
def _apply_rules(self, st_lines, excluded_ids=None, partner_map=None):
|
|
''' Apply criteria to get candidates for all reconciliation models.
|
|
|
|
This function is called in enterprise by the reconciliation widget to match
|
|
the statement lines with the available candidates (using the reconciliation models).
|
|
|
|
:param st_lines: Account.bank.statement.lines recordset.
|
|
:param excluded_ids: Account.move.lines to exclude.
|
|
:param partner_map: Dict mapping each line with new partner eventually.
|
|
:return: A dict mapping each statement line id with:
|
|
* aml_ids: A list of account.move.line ids.
|
|
* model: An account.reconcile.model record (optional).
|
|
* status: 'reconciled' if the lines has been already reconciled, 'write_off' if the write-off must be
|
|
applied on the statement line.
|
|
'''
|
|
# This functions uses SQL to compute its results. We need to flush before doing anything more.
|
|
for model_name in ('account.bank.statement', 'account.bank.statement.line', 'account.move', 'account.move.line', 'res.company', 'account.journal', 'account.account'):
|
|
self.env[model_name].flush(self.env[model_name]._fields)
|
|
|
|
results = {line.id: {'aml_ids': []} for line in st_lines}
|
|
|
|
available_models = self.filtered(lambda m: m.rule_type != 'writeoff_button').sorted()
|
|
aml_ids_to_exclude = set() # Keep track of already processed amls.
|
|
reconciled_amls_ids = set() # Keep track of already reconciled amls.
|
|
|
|
# First associate with each rec models all the statement lines for which it is applicable
|
|
lines_with_partner_per_model = defaultdict(lambda: [])
|
|
for st_line in st_lines:
|
|
|
|
# Statement lines created in old versions could have a residual amount of zero. In that case, don't try to
|
|
# match anything.
|
|
if not st_line.amount_residual:
|
|
continue
|
|
|
|
mapped_partner = (partner_map and partner_map.get(st_line.id) and self.env['res.partner'].browse(partner_map[st_line.id])) or st_line.partner_id
|
|
|
|
for rec_model in available_models:
|
|
partner = mapped_partner or rec_model._get_partner_from_mapping(st_line)
|
|
|
|
if rec_model._is_applicable_for(st_line, partner):
|
|
lines_with_partner_per_model[rec_model].append((st_line, partner))
|
|
|
|
# Execute only one SQL query for each model (for performance)
|
|
matched_lines = self.env['account.bank.statement.line']
|
|
for rec_model in available_models:
|
|
|
|
# We filter the lines for this model, in case a previous one has already found something for them
|
|
filtered_st_lines_with_partner = [x for x in lines_with_partner_per_model[rec_model] if x[0] not in matched_lines]
|
|
|
|
if not filtered_st_lines_with_partner:
|
|
# No unreconciled statement line for this model
|
|
continue
|
|
|
|
all_model_candidates = rec_model._get_candidates(filtered_st_lines_with_partner, excluded_ids)
|
|
|
|
for st_line, partner in filtered_st_lines_with_partner:
|
|
candidates = all_model_candidates[st_line.id]
|
|
if candidates:
|
|
model_rslt, new_reconciled_aml_ids, new_treated_aml_ids = rec_model._get_rule_result(st_line, candidates, aml_ids_to_exclude, reconciled_amls_ids, partner)
|
|
|
|
if model_rslt:
|
|
# We inject the selected partner (possibly coming from the rec model)
|
|
model_rslt['partner']= partner
|
|
|
|
results[st_line.id] = model_rslt
|
|
reconciled_amls_ids |= new_reconciled_aml_ids
|
|
aml_ids_to_exclude |= new_treated_aml_ids
|
|
matched_lines += st_line
|
|
|
|
return results
|
|
|
|
def _is_applicable_for(self, st_line, partner):
|
|
""" Returns true iff this reconciliation model can be used to search for matches
|
|
for the provided statement line and partner.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
# Filter on journals, amount nature, amount and partners
|
|
# All the conditions defined in this block are non-match conditions.
|
|
if ((self.match_journal_ids and st_line.move_id.journal_id not in self.match_journal_ids)
|
|
or (self.match_nature == 'amount_received' and st_line.amount < 0)
|
|
or (self.match_nature == 'amount_paid' and st_line.amount > 0)
|
|
or (self.match_amount == 'lower' and abs(st_line.amount) >= self.match_amount_max)
|
|
or (self.match_amount == 'greater' and abs(st_line.amount) <= self.match_amount_min)
|
|
or (self.match_amount == 'between' and (abs(st_line.amount) > self.match_amount_max or abs(st_line.amount) < self.match_amount_min))
|
|
or (self.match_partner and not partner)
|
|
or (self.match_partner and self.match_partner_ids and partner not in self.match_partner_ids)
|
|
or (self.match_partner and self.match_partner_category_ids and partner.category_id not in self.match_partner_category_ids)
|
|
):
|
|
return False
|
|
|
|
# Filter on label, note and transaction_type
|
|
for record, rule_field, record_field in [(st_line, 'label', 'payment_ref'), (st_line.move_id, 'note', 'narration'), (st_line, 'transaction_type', 'transaction_type')]:
|
|
rule_term = (self['match_' + rule_field + '_param'] or '').lower()
|
|
record_term = (record[record_field] or '').lower()
|
|
|
|
# This defines non-match conditions
|
|
if ((self['match_' + rule_field] == 'contains' and rule_term not in record_term)
|
|
or (self['match_' + rule_field] == 'not_contains' and rule_term in record_term)
|
|
or (self['match_' + rule_field] == 'match_regex' and not re.match(rule_term, record_term))
|
|
):
|
|
return False
|
|
|
|
return True
|
|
|
|
def _get_candidates(self, st_lines_with_partner, excluded_ids):
|
|
""" Returns the match candidates for this rule, with respect to the provided parameters.
|
|
|
|
:param st_lines_with_partner: A list of tuples (statement_line, partner),
|
|
associating each statement line to treate with
|
|
the corresponding partner, given by the partner map
|
|
:param excluded_ids: a set containing the ids of the amls to ignore during the search
|
|
(because they already been matched by another rule)
|
|
"""
|
|
self.ensure_one()
|
|
|
|
treatment_map = {
|
|
'invoice_matching': lambda x: x._get_invoice_matching_query(st_lines_with_partner, excluded_ids),
|
|
'writeoff_suggestion': lambda x: x._get_writeoff_suggestion_query(st_lines_with_partner, excluded_ids),
|
|
}
|
|
|
|
query_generator = treatment_map[self.rule_type]
|
|
query, params = query_generator(self)
|
|
self._cr.execute(query, params)
|
|
|
|
rslt = defaultdict(lambda: [])
|
|
for candidate_dict in self._cr.dictfetchall():
|
|
rslt[candidate_dict['id']].append(candidate_dict)
|
|
|
|
return rslt
|
|
|
|
def _get_invoice_matching_query(self, st_lines_with_partner, excluded_ids):
|
|
''' Returns the query applying the current invoice_matching reconciliation
|
|
model to the provided statement lines.
|
|
|
|
:param st_lines_with_partner: A list of tuples (statement_line, partner),
|
|
associating each statement line to treate with
|
|
the corresponding partner, given by the partner map
|
|
:param excluded_ids: Account.move.lines to exclude.
|
|
:return: (query, params)
|
|
'''
|
|
self.ensure_one()
|
|
if self.rule_type != 'invoice_matching':
|
|
raise UserError(_('Programmation Error: Can\'t call _get_invoice_matching_query() for different rules than \'invoice_matching\''))
|
|
|
|
unaccent = get_unaccent_wrapper(self._cr)
|
|
|
|
# N.B: 'communication_flag' is there to distinguish invoice matching through the number/reference
|
|
# (higher priority) from invoice matching using the partner (lower priority).
|
|
query = r'''
|
|
SELECT
|
|
st_line.id AS id,
|
|
aml.id AS aml_id,
|
|
aml.currency_id AS aml_currency_id,
|
|
aml.date_maturity AS aml_date_maturity,
|
|
aml.amount_residual AS aml_amount_residual,
|
|
aml.amount_residual_currency AS aml_amount_residual_currency,
|
|
''' + self._get_select_communication_flag() + r''' AS communication_flag,
|
|
''' + self._get_select_payment_reference_flag() + r''' AS payment_reference_flag
|
|
FROM account_bank_statement_line st_line
|
|
JOIN account_move st_line_move ON st_line_move.id = st_line.move_id
|
|
JOIN res_company company ON company.id = st_line_move.company_id
|
|
, account_move_line aml
|
|
LEFT JOIN account_move move ON move.id = aml.move_id AND move.state = 'posted'
|
|
LEFT JOIN account_account account ON account.id = aml.account_id
|
|
LEFT JOIN res_partner aml_partner ON aml.partner_id = aml_partner.id
|
|
WHERE
|
|
aml.company_id = st_line_move.company_id
|
|
AND move.state = 'posted'
|
|
AND account.reconcile IS TRUE
|
|
AND aml.reconciled IS FALSE
|
|
'''
|
|
|
|
# Add conditions to handle each of the statement lines we want to match
|
|
st_lines_queries = []
|
|
for st_line, partner in st_lines_with_partner:
|
|
# In case we don't have any partner for this line, we try assigning one with the rule mapping
|
|
if st_line.amount > 0:
|
|
st_line_subquery = r"aml.balance > 0"
|
|
else:
|
|
st_line_subquery = r"aml.balance < 0"
|
|
|
|
if self.match_same_currency:
|
|
st_line_subquery += r" AND COALESCE(aml.currency_id, company.currency_id) = %s" % (st_line.foreign_currency_id.id or st_line.move_id.currency_id.id)
|
|
|
|
if partner:
|
|
st_line_subquery += r" AND aml.partner_id = %s" % partner.id
|
|
else:
|
|
st_line_subquery += r"""
|
|
AND
|
|
(
|
|
substring(REGEXP_REPLACE(st_line.payment_ref, '[^0-9\s]', '', 'g'), '\S(?:.*\S)*') != ''
|
|
AND
|
|
(
|
|
(""" + self._get_select_communication_flag() + """)
|
|
OR
|
|
(""" + self._get_select_payment_reference_flag() + """)
|
|
)
|
|
)
|
|
OR
|
|
(
|
|
/* We also match statement lines without partners with amls
|
|
whose partner's name's parts (splitting on space) are all present
|
|
within the payment_ref, in any order, with any characters between them. */
|
|
|
|
aml_partner.name IS NOT NULL
|
|
AND """ + unaccent("st_line.payment_ref") + r""" ~* ('^' || (
|
|
SELECT string_agg(concat('(?=.*\m', chunk[1], '\M)'), '')
|
|
FROM regexp_matches(""" + unaccent("aml_partner.name") + r""", '\w{3,}', 'g') AS chunk
|
|
))
|
|
)
|
|
"""
|
|
|
|
st_lines_queries.append(r"st_line.id = %s AND (%s)" % (st_line.id, st_line_subquery))
|
|
|
|
query += r" AND (%s) " % " OR ".join(st_lines_queries)
|
|
|
|
params = {}
|
|
|
|
# If this reconciliation model defines a past_months_limit, we add a condition
|
|
# to the query to only search on move lines that are younger than this limit.
|
|
if self.past_months_limit:
|
|
date_limit = fields.Date.context_today(self) - relativedelta(months=self.past_months_limit)
|
|
query += "AND aml.date >= %(aml_date_limit)s"
|
|
params['aml_date_limit'] = date_limit
|
|
|
|
# Filter out excluded account.move.line.
|
|
if excluded_ids:
|
|
query += 'AND aml.id NOT IN %(excluded_aml_ids)s'
|
|
params['excluded_aml_ids'] = tuple(excluded_ids)
|
|
|
|
if self.matching_order == 'new_first':
|
|
query += ' ORDER BY aml_date_maturity DESC, aml_id DESC'
|
|
else:
|
|
query += ' ORDER BY aml_date_maturity ASC, aml_id ASC'
|
|
|
|
return query, params
|
|
|
|
def _get_select_communication_flag(self):
|
|
self.ensure_one()
|
|
# Determine a matching or not with the statement line communication using the aml.name, move.name or move.ref.
|
|
st_ref_list = []
|
|
if self.match_text_location_label:
|
|
st_ref_list += ['st_line.payment_ref']
|
|
if self.match_text_location_note:
|
|
st_ref_list += ['st_line_move.narration']
|
|
if self.match_text_location_reference:
|
|
st_ref_list += ['st_line_move.ref']
|
|
|
|
st_ref = " || ' ' || ".join(
|
|
"COALESCE(%s, '')" % st_ref_name
|
|
for st_ref_name in st_ref_list
|
|
)
|
|
if not st_ref:
|
|
return "FALSE"
|
|
|
|
statement_compare = r"""(
|
|
{move_field} IS NOT NULL AND substring(REGEXP_REPLACE({move_field}, '[^0-9\s]', '', 'g'), '\S(?:.*\S)*') != ''
|
|
AND (
|
|
regexp_split_to_array(substring(REGEXP_REPLACE({move_field}, '[^0-9\s]', '', 'g'), '\S(?:.*\S)*'),'\s+')
|
|
&& regexp_split_to_array(substring(REGEXP_REPLACE({st_ref}, '[^0-9\s]', '', 'g'), '\S(?:.*\S)*'), '\s+')
|
|
)
|
|
)"""
|
|
return " OR ".join(
|
|
statement_compare.format(move_field=field, st_ref=st_ref)
|
|
for field in ['aml.name', 'move.name', 'move.ref']
|
|
)
|
|
|
|
def _get_select_payment_reference_flag(self):
|
|
# Determine a matching or not with the statement line communication using the move.payment_reference.
|
|
st_ref_list = []
|
|
if self.match_text_location_label:
|
|
st_ref_list += ['st_line.payment_ref']
|
|
if self.match_text_location_note:
|
|
st_ref_list += ['st_line_move.narration']
|
|
if self.match_text_location_reference:
|
|
st_ref_list += ['st_line_move.ref']
|
|
if not st_ref_list:
|
|
return "FALSE"
|
|
return r'''(move.payment_reference IS NOT NULL AND ({}))'''.format(
|
|
' OR '.join(
|
|
rf"regexp_replace(move.payment_reference, '\s+', '', 'g') = regexp_replace({st_ref}, '\s+', '', 'g')"
|
|
for st_ref in st_ref_list
|
|
)
|
|
)
|
|
|
|
def _get_partner_from_mapping(self, st_line):
|
|
"""Find partner with mapping defined on model.
|
|
|
|
For invoice matching rules, matches the statement line against each
|
|
regex defined in partner mapping, and returns the partner corresponding
|
|
to the first one matching.
|
|
|
|
:param st_line (Model<account.bank.statement.line>):
|
|
The statement line that needs a partner to be found
|
|
:return Model<res.partner>:
|
|
The partner found from the mapping. Can be empty an empty recordset
|
|
if there was nothing found from the mapping or if the function is
|
|
not applicable.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
if self.rule_type not in ('invoice_matching', 'writeoff_suggestion'):
|
|
return self.env['res.partner']
|
|
|
|
for partner_mapping in self.partner_mapping_line_ids:
|
|
match_payment_ref = re.match(partner_mapping.payment_ref_regex, st_line.payment_ref) if partner_mapping.payment_ref_regex else True
|
|
match_narration = re.match(partner_mapping.narration_regex, st_line.narration or '') if partner_mapping.narration_regex else True
|
|
|
|
if match_payment_ref and match_narration:
|
|
return partner_mapping.partner_id
|
|
return self.env['res.partner']
|
|
|
|
def _get_writeoff_suggestion_query(self, st_lines_with_partner, excluded_ids=None):
|
|
''' Returns the query applying the current writeoff_suggestion reconciliation
|
|
model to the provided statement lines.
|
|
|
|
:param st_lines_with_partner: A list of tuples (statement_line, partner),
|
|
associating each statement line to treate with
|
|
the corresponding partner, given by the partner map
|
|
:param excluded_ids: Account.move.lines to exclude.
|
|
:return: (query, params)
|
|
'''
|
|
self.ensure_one()
|
|
|
|
if self.rule_type != 'writeoff_suggestion':
|
|
raise UserError(_("Programmation Error: Can't call _get_writeoff_suggestion_query() for different rules than 'writeoff_suggestion'"))
|
|
|
|
query = '''
|
|
SELECT
|
|
st_line.id AS id
|
|
FROM account_bank_statement_line st_line
|
|
WHERE st_line.id IN %(st_line_ids)s
|
|
'''
|
|
params = {
|
|
'st_line_ids': tuple(st_line.id for (st_line, partner) in st_lines_with_partner),
|
|
}
|
|
|
|
return query, params
|
|
|
|
def _get_rule_result(self, st_line, candidates, aml_ids_to_exclude, reconciled_amls_ids, partner_map):
|
|
""" Get the result of a rule from the list of available candidates, depending on the
|
|
other reconciliations performed by previous rules.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
if self.rule_type == 'invoice_matching':
|
|
return self._get_invoice_matching_rule_result(st_line, candidates, aml_ids_to_exclude, reconciled_amls_ids, partner_map)
|
|
elif self.rule_type == 'writeoff_suggestion':
|
|
return self._get_writeoff_suggestion_rule_result(st_line, partner_map), set(), set()
|
|
else:
|
|
return None, set(), set()
|
|
|
|
def _get_invoice_matching_rule_result(self, st_line, candidates, aml_ids_to_exclude, reconciled_amls_ids, partner):
|
|
new_reconciled_aml_ids = set()
|
|
new_treated_aml_ids = set()
|
|
candidates, priorities = self._filter_candidates(candidates, aml_ids_to_exclude, reconciled_amls_ids)
|
|
|
|
# Special case: the amounts are the same, submit the line directly.
|
|
st_line_currency = st_line.foreign_currency_id or st_line.currency_id
|
|
candidate_currencies = set(candidate['aml_currency_id'] or st_line.company_id.currency_id.id for candidate in candidates)
|
|
if candidate_currencies == {st_line_currency.id}:
|
|
for candidate in candidates:
|
|
residual_amount = candidate['aml_currency_id'] and candidate['aml_amount_residual_currency'] or candidate['aml_amount_residual']
|
|
if st_line_currency.is_zero(residual_amount + st_line.amount_residual):
|
|
candidates, priorities = self._filter_candidates([candidate], aml_ids_to_exclude, reconciled_amls_ids)
|
|
break
|
|
|
|
# We check the amount criteria of the reconciliation model, and select the
|
|
# candidates if they pass the verification. Candidates from the first priority
|
|
# level (even already selected) bypass this check, and are selected anyway.
|
|
disable_bypass = self.env['ir.config_parameter'].sudo().get_param('account.disable_rec_models_bypass')
|
|
if (not disable_bypass and priorities & {1,2}) or self._check_rule_propositions(st_line, candidates):
|
|
rslt = {
|
|
'model': self,
|
|
'aml_ids': [candidate['aml_id'] for candidate in candidates],
|
|
}
|
|
new_treated_aml_ids = set(rslt['aml_ids'])
|
|
|
|
# Create write-off lines.
|
|
lines_vals_list = self._prepare_reconciliation(st_line, aml_ids=rslt['aml_ids'], partner=partner)
|
|
|
|
# A write-off must be applied if there are some 'new' lines to propose.
|
|
write_off_lines_vals = list(filter(lambda x: 'id' not in x, lines_vals_list))
|
|
if not lines_vals_list or write_off_lines_vals:
|
|
rslt['status'] = 'write_off'
|
|
rslt['write_off_vals'] = write_off_lines_vals
|
|
|
|
# Process auto-reconciliation. We only do that for the first two priorities, if they are not matched elsewhere.
|
|
if lines_vals_list and priorities & {1, 3} and self.auto_reconcile:
|
|
if not st_line.partner_id and partner:
|
|
st_line.partner_id = partner
|
|
|
|
st_line.reconcile(lines_vals_list)
|
|
rslt['status'] = 'reconciled'
|
|
rslt['reconciled_lines'] = st_line.line_ids
|
|
new_reconciled_aml_ids = new_treated_aml_ids
|
|
else:
|
|
rslt = None
|
|
|
|
return rslt, new_reconciled_aml_ids, new_treated_aml_ids
|
|
|
|
def _check_rule_propositions(self, statement_line, candidates):
|
|
''' Check restrictions that can't be handled for each move.line separately.
|
|
/!\ Only used by models having a type equals to 'invoice_matching'.
|
|
:param statement_line: An account.bank.statement.line record.
|
|
:param candidates: Fetched account.move.lines from query (dict).
|
|
:return: True if the reconciliation propositions are accepted. False otherwise.
|
|
'''
|
|
if not self.match_total_amount:
|
|
return True
|
|
if not candidates:
|
|
return False
|
|
|
|
reconciliation_overview, open_balance_vals = statement_line._prepare_reconciliation([{
|
|
'currency_id': aml['aml_currency_id'],
|
|
'amount_residual': aml['aml_amount_residual'],
|
|
'amount_residual_currency': aml['aml_amount_residual_currency'],
|
|
} for aml in candidates])
|
|
|
|
# Match total residual amount.
|
|
line_currency = statement_line.foreign_currency_id or statement_line.currency_id
|
|
line_residual = statement_line.amount_residual
|
|
line_residual_after_reconciliation = line_residual
|
|
|
|
for reconciliation_vals in reconciliation_overview:
|
|
line_vals = reconciliation_vals['line_vals']
|
|
if line_vals['currency_id']:
|
|
line_residual_after_reconciliation -= line_vals['amount_currency']
|
|
else:
|
|
line_residual_after_reconciliation -= line_vals['debit'] - line_vals['credit']
|
|
|
|
# Statement line amount is equal to the total residual.
|
|
if line_currency.is_zero(line_residual_after_reconciliation):
|
|
return True
|
|
|
|
reconciled_percentage = (abs(line_residual) - abs(line_residual_after_reconciliation)) / abs(line_residual) * 100
|
|
return reconciled_percentage >= self.match_total_amount_param
|
|
|
|
def _filter_candidates(self, candidates, aml_ids_to_exclude, reconciled_amls_ids):
|
|
""" Sorts reconciliation candidates by priority and filters them so that only
|
|
the most prioritary are kept.
|
|
"""
|
|
candidates_by_priority = self._sort_reconciliation_candidates_by_priority(candidates, aml_ids_to_exclude, reconciled_amls_ids)
|
|
max_priority = min(candidates_by_priority.keys())
|
|
|
|
filtered_candidates = candidates_by_priority[max_priority]
|
|
filtered_priorities = {max_priority,}
|
|
|
|
if max_priority in (1, 3, 5):
|
|
# We also keep the already proposed values of the same priority level
|
|
proposed_priority = max_priority + 1
|
|
filtered_candidates += candidates_by_priority[proposed_priority]
|
|
if candidates_by_priority[proposed_priority]:
|
|
filtered_priorities.add(proposed_priority)
|
|
|
|
return filtered_candidates, filtered_priorities
|
|
|
|
def _sort_reconciliation_candidates_by_priority(self, candidates, already_proposed_aml_ids, already_reconciled_aml_ids):
|
|
""" Sorts the provided candidates and returns a mapping of candidates by
|
|
priority (1 being the highest).
|
|
|
|
The priorities are defined as follows:
|
|
|
|
1: payment_reference_flag is true, so the move's payment_reference
|
|
field matches the statement line's.
|
|
|
|
2: Same as 1, but the candidates have already been proposed for a previous statement line
|
|
|
|
3: communication_flag is true, so either the move's ref, move's name or
|
|
aml's name match the statement line's payment reference.
|
|
|
|
4: Same as 3, but the candidates have already been proposed for a previous statement line
|
|
|
|
5: candidates proposed by the query, but no match with the statement
|
|
line's payment ref could be found.
|
|
|
|
6: Same as 5, but the candidates have already been proposed for a previous statement line
|
|
"""
|
|
candidates_by_priority = defaultdict(lambda: [])
|
|
|
|
for candidate in filter(lambda x: x['aml_id'] not in already_reconciled_aml_ids, candidates):
|
|
|
|
if candidate['payment_reference_flag']:
|
|
priority = 1
|
|
elif candidate['communication_flag']:
|
|
priority = 3
|
|
else:
|
|
priority = 5
|
|
|
|
if candidate['aml_id'] in already_proposed_aml_ids:
|
|
# So, priorities 2, 4 and 6 are created here
|
|
priority += 1
|
|
|
|
candidates_by_priority[priority].append(candidate)
|
|
|
|
return candidates_by_priority
|
|
|
|
def _get_writeoff_suggestion_rule_result(self, st_line, partner):
|
|
# Create write-off lines.
|
|
lines_vals_list = self._prepare_reconciliation(st_line, partner=partner)
|
|
|
|
rslt = {
|
|
'model': self,
|
|
'status': 'write_off',
|
|
'aml_ids': [],
|
|
'write_off_vals': lines_vals_list,
|
|
}
|
|
|
|
# Process auto-reconciliation.
|
|
if lines_vals_list and self.auto_reconcile:
|
|
if not st_line.partner_id and partner:
|
|
st_line.partner_id = partner
|
|
|
|
st_line.reconcile(lines_vals_list)
|
|
rslt['status'] = 'reconciled'
|
|
rslt['reconciled_lines'] = st_line.line_ids
|
|
|
|
return rslt
|