summaryrefslogtreecommitdiff
path: root/addons/account/models/account_reconcile_model.py
diff options
context:
space:
mode:
Diffstat (limited to 'addons/account/models/account_reconcile_model.py')
-rw-r--r--addons/account/models/account_reconcile_model.py988
1 files changed, 988 insertions, 0 deletions
diff --git a/addons/account/models/account_reconcile_model.py b/addons/account/models/account_reconcile_model.py
new file mode 100644
index 00000000..d6513689
--- /dev/null
+++ b/addons/account/models/account_reconcile_model.py
@@ -0,0 +1,988 @@
+# -*- 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."))
+ try:
+ if record.payment_ref_regex:
+ current_regex = record.payment_ref_regex
+ re.compile(record.payment_ref_regex)
+ if record.narration_regex:
+ current_regex = record.narration_regex
+ re.compile(record.narration_regex)
+ except re.error:
+ raise ValidationError(_("The following regular expression is invalid to create a partner mapping: %s") % current_regex)
+
+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']
+ tax_type = tax.type_tax_use
+ is_refund = (tax_type == 'sale' and balance < 0) or (tax_type == 'purchase' and balance > 0)
+
+ res = tax.compute_all(balance, is_refund=is_refund)
+
+ if (tax_type == 'sale' and not is_refund) or (tax_type == 'purchase' and is_refund):
+ base_tags = self.env['account.account.tag'].browse(res['base_tags'])
+ res['base_tags'] = self.env['account.move.line']._revert_signed_tags(base_tags).ids
+
+ for tax_result in res['taxes']:
+ tax_tags = self.env['account.account.tag'].browse(tax_result['tag_ids'])
+ tax_result['tag_ids'] = self.env['account.move.line']._revert_signed_tags(tax_tags).ids
+
+ 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
+ try:
+ extracted_balance = float(re.sub(r'\D' + self.decimal_separator, '', match.group(1)).replace(self.decimal_separator, '.'))
+ balance = copysign(extracted_balance * sign, residual_balance)
+ except ValueError:
+ balance = 0
+ 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
+ LEFT JOIN account_payment payment ON payment.move_id = move.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"
+
+ # payment_reference is not used on account.move for payments; ref is used instead
+ return r'''((move.payment_reference IS NOT NULL OR (payment.id IS NOT NULL AND move.ref IS NOT NULL)) AND ({}))'''.format(
+ ' OR '.join(
+ rf"regexp_replace(CASE WHEN payment.id IS NULL THEN move.payment_reference ELSE move.ref END, '\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
+ residual_difference = line_residual - line_residual_after_reconciliation
+ reconciled_percentage = 100 - abs(line_residual_after_reconciliation) / abs(residual_difference) * 100 if (residual_difference != 0) else 0
+ 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)
+
+ # This can happen if the candidates were already reconciled at this point
+ if not candidates_by_priority:
+ return [], set()
+
+ 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