From 080d41a34119191d20b3e07f72f6aeec677ee1a8 Mon Sep 17 00:00:00 2001 From: Laurent Smet Date: Tue, 22 Jun 2021 14:01:24 +0000 Subject: [PATCH] [REF] account_accountant: Refactor the bank reconciliation widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor the bank reconciliation widget to use more standards views and have a side-by-side kanban/widget views instead. - Make sure the bank reconciliation widget is doing the same thing as the "reconcile" method on account.bank.statement.line. Then, what you see on the view is exactly what you get on the corresponding journal entry. - Since the "reconcile" method is gone, clean the matching rules since it's now called only for one statement line at a time. Also, move the auto_validate feature into a CRON to avoid performance issues when opening the widget. closes odoo/odoo#91448 Task: 2555114 Related: odoo/enterprise#27360 Related: odoo/upgrade#3555 Signed-off-by: William André (wan) --- .../account/models/account_bank_statement.py | 628 +++++------- .../models/account_journal_dashboard.py | 9 +- addons/account/models/account_move.py | 76 +- .../account/models/account_reconcile_model.py | 896 +++++++----------- addons/account/models/company.py | 32 +- .../tests/test_account_bank_statement.py | 775 +-------------- addons/account/tests/test_account_payment.py | 9 +- .../test_reconciliation_matching_rules.py | 673 +++++-------- .../views/account_journal_dashboard_view.xml | 5 +- addons/account/views/account_move_views.xml | 1 - addons/hr_expense/tests/test_expenses.py | 9 +- .../data/account_reconcile_model_template.xml | 12 - .../static/tests/tours/main_flow.js | 12 +- 13 files changed, 902 insertions(+), 2235 deletions(-) diff --git a/addons/account/models/account_bank_statement.py b/addons/account/models/account_bank_statement.py index dc7addc2b83..6bf6ebadeef 100644 --- a/addons/account/models/account_bank_statement.py +++ b/addons/account/models/account_bank_statement.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- -import math - from odoo import api, fields, models, _ -from odoo.tools import float_is_zero +from odoo.tools import float_is_zero, html2plaintext from odoo.tools.misc import formatLang, format_date from odoo.exceptions import UserError, ValidationError +from odoo.osv.expression import get_unaccent_wrapper +from odoo.addons.base.models.res_bank import sanitize_account_number class AccountCashboxLine(models.Model): @@ -524,10 +524,16 @@ class AccountBankStatementLine(models.Model): transaction_type = fields.Char(string='Transaction Type') payment_ref = fields.Char(string='Label', required=True) amount = fields.Monetary(currency_field='currency_id') - amount_currency = fields.Monetary(currency_field='foreign_currency_id', - help="The amount expressed in an optional other currency if it is a multi-currency entry.") - foreign_currency_id = fields.Many2one('res.currency', string='Foreign Currency', - help="The optional other currency if it is a multi-currency entry.") + amount_currency = fields.Monetary( + string="Amount in Currency", + currency_field='foreign_currency_id', + help="The amount expressed in an optional other currency if it is a multi-currency entry.", + ) + foreign_currency_id = fields.Many2one( + comodel_name='res.currency', + string="Foreign Currency", + help="The optional other currency if it is a multi-currency entry.", + ) amount_residual = fields.Float(string="Residual Amount", compute="_compute_is_reconciled", store=True, @@ -575,156 +581,68 @@ class AccountBankStatementLine(models.Model): other_lines += line return liquidity_lines, suspense_lines, other_lines - @api.model - def _prepare_liquidity_move_line_vals(self): - ''' Prepare values to create a new account.move.line record corresponding to the - liquidity line (having the bank/cash account). - :return: The values to create a new account.move.line record. - ''' + def _prepare_counterpart_amounts_using_st_line_rate(self, currency, balance, amount_currency): + """ Convert the amounts passed as parameters to the statement line currency using the rates provided by the + bank. The computed amounts are the one that could be set on the statement line as a counterpart journal item + to fully paid the provided amounts as parameters. + + :param currency: The currency in which is expressed 'amount_currency'. + :param balance: The amount expressed in company currency. Only needed when the currency passed as + parameter is neither the statement line's foreign currency, neither the journal's + currency. + :param amount_currency: The amount expressed in the 'currency' passed as parameter. + :return: A python dictionary containing: + * balance: The amount to consider expressed in company's currency. + * amount_currency: The amount to consider expressed in statement line's foreign currency. + """ self.ensure_one() - statement = self.statement_id - journal = statement.journal_id - company_currency = journal.company_id.currency_id - journal_currency = journal.currency_id or company_currency - - if self.foreign_currency_id and journal_currency: - currency_id = journal_currency.id - if self.foreign_currency_id == company_currency: - amount_currency = self.amount - balance = self.amount_currency - else: - amount_currency = self.amount - balance = journal_currency._convert(amount_currency, company_currency, journal.company_id, self.date) - elif self.foreign_currency_id and not journal_currency: - amount_currency = self.amount_currency - balance = self.amount - currency_id = self.foreign_currency_id.id - elif not self.foreign_currency_id and journal_currency: - currency_id = journal_currency.id - amount_currency = self.amount - balance = journal_currency._convert(amount_currency, journal.company_id.currency_id, journal.company_id, self.date) - else: - currency_id = company_currency.id - amount_currency = self.amount - balance = self.amount - - return { - 'name': self.payment_ref, - 'move_id': self.move_id.id, - 'partner_id': self.partner_id.id, - 'currency_id': currency_id, - 'account_id': journal.default_account_id.id, - 'debit': balance > 0 and balance or 0.0, - 'credit': balance < 0 and -balance or 0.0, - 'amount_currency': amount_currency, - } - - @api.model - def _prepare_counterpart_move_line_vals(self, counterpart_vals, move_line=None): - ''' Prepare values to create a new account.move.line move_line. - By default, without specified 'counterpart_vals' or 'move_line', the counterpart line is - created using the suspense account. Otherwise, this method is also called during the - reconciliation to prepare the statement line's journal entry. In that case, - 'counterpart_vals' will be used to create a custom account.move.line (from the reconciliation widget) - and 'move_line' will be used to create the counterpart of an existing account.move.line to which - the newly created journal item will be reconciled. - :param counterpart_vals: A python dictionary containing: - 'balance': Optional amount to consider during the reconciliation. If a foreign currency is set on the - counterpart line in the same foreign currency as the statement line, then this amount is - considered as the amount in foreign currency. If not specified, the full balance is took. - This value must be provided if move_line is not. - 'amount_residual': The residual amount to reconcile expressed in the company's currency. - /!\ This value should be equivalent to move_line.amount_residual except we want - to avoid browsing the record when the only thing we need in an overview of the - reconciliation, for example in the reconciliation widget. - 'amount_residual_currency': The residual amount to reconcile expressed in the foreign's currency. - Using this key doesn't make sense without passing 'currency_id' in vals. - /!\ This value should be equivalent to move_line.amount_residual_currency except - we want to avoid browsing the record when the only thing we need in an overview - of the reconciliation, for example in the reconciliation widget. - **kwargs: Additional values that need to land on the account.move.line to create. - :param move_line: An optional account.move.line move_line representing the counterpart line to reconcile. - :return: The values to create a new account.move.line move_line. - ''' - self.ensure_one() - - statement = self.statement_id - journal = statement.journal_id + journal = self.journal_id company_currency = journal.company_id.currency_id journal_currency = journal.currency_id or company_currency foreign_currency = self.foreign_currency_id or journal_currency or company_currency - statement_line_rate = (self.amount_currency / self.amount) if self.amount else 0.0 - balance_to_reconcile = counterpart_vals.pop('balance', None) - amount_residual = -counterpart_vals.pop('amount_residual', move_line.amount_residual if move_line else 0.0) \ - if balance_to_reconcile is None else balance_to_reconcile - amount_residual_currency = -counterpart_vals.pop('amount_residual_currency', move_line.amount_residual_currency if move_line else 0.0)\ - if balance_to_reconcile is None else balance_to_reconcile - - if 'currency_id' in counterpart_vals: - currency_id = counterpart_vals['currency_id'] or company_currency.id - elif move_line: - currency_id = move_line.currency_id.id or company_currency.id + journal_amount = self.amount + if foreign_currency == journal_currency: + transaction_amount = journal_amount else: - currency_id = foreign_currency.id - - if currency_id not in (foreign_currency.id, journal_currency.id): - currency_id = company_currency.id - amount_residual_currency = 0.0 - - amounts = { - company_currency.id: 0.0, - journal_currency.id: 0.0, - foreign_currency.id: 0.0, - } - - amounts[currency_id] = amount_residual_currency - amounts[company_currency.id] = amount_residual - - if currency_id == journal_currency.id and journal_currency != company_currency: - if foreign_currency != company_currency: - amounts[company_currency.id] = journal_currency._convert(amounts[currency_id], company_currency, journal.company_id, self.date) - if statement_line_rate: - amounts[foreign_currency.id] = amounts[currency_id] * statement_line_rate - elif currency_id == foreign_currency.id and self.foreign_currency_id: - if statement_line_rate: - amounts[journal_currency.id] = amounts[foreign_currency.id] / statement_line_rate - if foreign_currency != company_currency: - amounts[company_currency.id] = journal_currency._convert(amounts[journal_currency.id], company_currency, journal.company_id, self.date) + transaction_amount = self.amount_currency + if journal_currency == company_currency: + company_amount = journal_amount + elif foreign_currency == company_currency: + company_amount = transaction_amount else: - amounts[journal_currency.id] = company_currency._convert(amounts[company_currency.id], journal_currency, journal.company_id, self.date) - if statement_line_rate: - amounts[foreign_currency.id] = amounts[journal_currency.id] * statement_line_rate + company_amount = journal_currency._convert(journal_amount, company_currency, journal.company_id, self.date) - if foreign_currency == company_currency and journal_currency != company_currency and self.foreign_currency_id: - balance = amounts[foreign_currency.id] - else: - balance = amounts[company_currency.id] + rate_journal2foreign_curr = journal_amount and abs(transaction_amount) / abs(journal_amount) + rate_comp2journal_curr = company_amount and abs(journal_amount) / abs(company_amount) - if foreign_currency != company_currency and self.foreign_currency_id: - amount_currency = amounts[foreign_currency.id] - currency_id = foreign_currency.id - elif journal_currency != company_currency and not self.foreign_currency_id: - amount_currency = amounts[journal_currency.id] - currency_id = journal_currency.id + if currency == foreign_currency: + trans_amount_currency = amount_currency + if rate_journal2foreign_curr: + journ_amount_currency = journal_currency.round(trans_amount_currency / rate_journal2foreign_curr) + else: + journ_amount_currency = 0.0 + if rate_comp2journal_curr: + new_balance = company_currency.round(journ_amount_currency / rate_comp2journal_curr) + else: + new_balance = 0.0 + elif currency == journal_currency: + trans_amount_currency = foreign_currency.round(amount_currency * rate_journal2foreign_curr) + if rate_comp2journal_curr: + new_balance = company_currency.round(amount_currency / rate_comp2journal_curr) + else: + new_balance = 0.0 else: - amount_currency = amounts[company_currency.id] - currency_id = company_currency.id + journ_amount_currency = journal_currency.round(balance * rate_comp2journal_curr) + trans_amount_currency = foreign_currency.round(journ_amount_currency * rate_journal2foreign_curr) + new_balance = balance return { - **counterpart_vals, - 'name': counterpart_vals.get('name', move_line.name if move_line else ''), - 'move_id': self.move_id.id, - 'partner_id': self.partner_id.id or counterpart_vals.get('partner_id', move_line.partner_id.id if move_line else False), - 'currency_id': currency_id, - 'account_id': counterpart_vals.get('account_id', move_line.account_id.id if move_line else False), - 'debit': balance if balance > 0.0 else 0.0, - 'credit': -balance if balance < 0.0 else 0.0, - 'amount_currency': amount_currency, + 'amount_currency': trans_amount_currency, + 'balance': new_balance, } - @api.model def _prepare_move_line_default_vals(self, counterpart_account_id=None): ''' Prepare the dictionary to create the default account.move.lines for the current account.bank.statement.line record. @@ -737,44 +655,49 @@ class AccountBankStatementLine(models.Model): if not counterpart_account_id: raise UserError(_( - "You can't create a new statement line without a suspense account set on the %s journal." - ) % self.journal_id.display_name) + "You can't create a new statement line without a suspense account set on the %s journal.", + self.journal_id.display_name, + )) - liquidity_line_vals = self._prepare_liquidity_move_line_vals() + journal = self.journal_id + company_currency = journal.company_id.currency_id + journal_currency = journal.currency_id or company_currency + foreign_currency = self.foreign_currency_id or journal_currency or company_currency - # Ensure the counterpart will have a balance exactly equals to the amount in journal currency. - # This avoid some rounding issues when the currency rate between two currencies is not symmetrical. - # E.g: - # A.convert(amount_a, B) = amount_b - # B.convert(amount_b, A) = amount_c != amount_a + journal_amount = self.amount + if foreign_currency == journal_currency: + transaction_amount = journal_amount + else: + transaction_amount = self.amount_currency + if journal_currency == company_currency: + company_amount = journal_amount + elif foreign_currency == company_currency: + company_amount = transaction_amount + else: + company_amount = journal_currency._convert(journal_amount, company_currency, journal.company_id, self.date) - counterpart_vals = { + liquidity_line_vals = { 'name': self.payment_ref, - 'account_id': counterpart_account_id, - 'amount_residual': liquidity_line_vals['debit'] - liquidity_line_vals['credit'], + 'move_id': self.move_id.id, + 'partner_id': self.partner_id.id, + 'account_id': journal.default_account_id.id, + 'currency_id': journal_currency.id, + 'amount_currency': journal_amount, + 'debit': company_amount > 0 and company_amount or 0.0, + 'credit': company_amount < 0 and -company_amount or 0.0, } - if self.foreign_currency_id and self.foreign_currency_id != self.company_currency_id: - # Ensure the counterpart will have exactly the same amount in foreign currency as the amount set in the - # statement line to avoid some rounding issues when making a currency conversion. - - counterpart_vals.update({ - 'currency_id': self.foreign_currency_id.id, - 'amount_residual_currency': self.amount_currency, - }) - elif liquidity_line_vals['currency_id']: - # Ensure the counterpart will have a balance exactly equals to the amount in journal currency. - # This avoid some rounding issues when the currency rate between two currencies is not symmetrical. - # E.g: - # A.convert(amount_a, B) = amount_b - # B.convert(amount_b, A) = amount_c != amount_a - - counterpart_vals.update({ - 'currency_id': liquidity_line_vals['currency_id'], - 'amount_residual_currency': liquidity_line_vals['amount_currency'], - }) - - counterpart_line_vals = self._prepare_counterpart_move_line_vals(counterpart_vals) + # Create the counterpart line values. + counterpart_line_vals = { + 'name': self.payment_ref, + 'account_id': counterpart_account_id, + 'move_id': self.move_id.id, + 'partner_id': self.partner_id.id, + 'currency_id': foreign_currency.id, + 'amount_currency': -transaction_amount, + 'debit': -company_amount if company_amount < 0.0 else 0.0, + 'credit': company_amount if company_amount > 0.0 else 0.0, + } return [liquidity_line_vals, counterpart_line_vals] # ------------------------------------------------------------------------- @@ -1013,246 +936,131 @@ class AccountBankStatementLine(models.Model): st_line_vals['partner_id'] = st_line.partner_id.id st_line.move_id.write(st_line_vals) - # ------------------------------------------------------------------------- - # RECONCILIATION METHODS - # ------------------------------------------------------------------------- - - def _prepare_reconciliation(self, lines_vals_list, allow_partial=False): - ''' Helper for the "reconcile" method used to get a full preview of the reconciliation result. This method is - quite useful to deal with reconcile models or the reconciliation widget because it ensures the values seen by - the user are exactly the values you get after reconciling. - - :param lines_vals_list: See the 'reconcile' method. - :param allow_partial: In case of matching a line having an higher amount, allow creating a partial instead - an open balance on the statement line. - :return: The diff to be applied on the statement line as a tuple - ( - lines_to_create: The values to create the account.move.line on the statement line. - payments_to_create: The values to create the account.payments. - open_balance_vals: A dictionary to create the open-balance line or None if the reconciliation is full. - existing_lines: The counterpart lines to which the reconciliation will be done. - ) - ''' - self.ensure_one() - journal = self.journal_id - company_currency = journal.company_id.currency_id - foreign_currency = self.foreign_currency_id or journal.currency_id or company_currency - - liquidity_lines, suspense_lines, other_lines = self._seek_for_lines() - - # Ensure the statement line has not yet been already reconciled. - # If the move has 'to_check' enabled, it means the statement line has created some lines that - # need to be checked later and replaced by the real ones. - if not self.move_id.to_check and other_lines: - raise UserError(_("The statement line has already been reconciled.")) - - # A list of dictionary containing: - # - line_vals: The values to create the account.move.line on the statement line. - # - payment_vals: The optional values to create a bridge account.payment - # - counterpart_line: The optional counterpart line to reconcile with 'line'. - reconciliation_overview = [] - - total_balance = liquidity_lines.balance - total_amount_currency = liquidity_lines.amount_currency - sign = 1 if liquidity_lines.balance > 0.0 else -1 - - # Step 1: Split 'lines_vals_list' into two batches: - # - The existing account.move.lines that need to be reconciled with the statement line. - # => Will be managed at step 2. - # - The account.move.lines to be created from scratch. - # => Will be managed directly. - - # In case of the payment is matched directly with an higher amount, don't create an open - # balance but a partial reconciliation. - partial_rec_needed = allow_partial - - to_browse_ids = [] - to_process_vals = [] - for vals in lines_vals_list: - # Don't modify the params directly. - vals = dict(vals) - - if 'id' in vals: - # Existing account.move.line. - to_browse_ids.append(vals.pop('id')) - to_process_vals.append(vals) - if any(x in vals for x in ('balance', 'amount_residual', 'amount_residual_currency')): - partial_rec_needed = False - else: - # Newly created account.move.line from scratch. - line_vals = self._prepare_counterpart_move_line_vals(vals) - total_balance += line_vals['debit'] - line_vals['credit'] - total_amount_currency += line_vals['amount_currency'] - reconciliation_overview.append({'line_vals': line_vals}) - partial_rec_needed = False - - # Step 2: Browse counterpart lines all in one and process them. - - existing_lines = self.env['account.move.line'].browse(to_browse_ids) - - i = 0 - for line, counterpart_vals in zip(existing_lines, to_process_vals): - line_vals = self._prepare_counterpart_move_line_vals(counterpart_vals, move_line=line) - balance = line_vals['debit'] - line_vals['credit'] - amount_currency = line_vals['amount_currency'] - i += 1 - - if i == len(existing_lines): - # Last line. - - if partial_rec_needed and sign * (total_amount_currency + amount_currency) < 0.0: - - # On the last aml, when the total matched amount becomes higher than the residual amount of the - # statement line, make sure to not create an open balance later. - line_vals = self._prepare_counterpart_move_line_vals( - { - **counterpart_vals, - 'amount_residual': -math.copysign(total_balance, balance), - 'amount_residual_currency': -math.copysign(total_amount_currency, amount_currency), - 'currency_id': foreign_currency.id, - }, - move_line=line, - ) - balance = line_vals['debit'] - line_vals['credit'] - amount_currency = line_vals['amount_currency'] - - elif sign * total_amount_currency < 0.0: - # The partial reconciliation is no longer an option since the total matched amount is now higher than - # the residual amount of the statement line but this is not the last line to process. Then, since we - # don't want to create zero balance lines, do nothing and let the open-balance be created like it - # should. - partial_rec_needed = False - - total_balance += balance - total_amount_currency += amount_currency - - reconciliation_overview.append({ - 'line_vals': line_vals, - 'counterpart_line': line, - }) - - # Step 3: Fix rounding issue due to currency conversions. - # Add the remaining balance on the first encountered line starting with the custom ones. - - if foreign_currency.is_zero(total_amount_currency) and not company_currency.is_zero(total_balance): - vals = reconciliation_overview[0]['line_vals'] - new_balance = vals['debit'] - vals['credit'] - total_balance - vals.update({ - 'debit': new_balance if new_balance > 0.0 else 0.0, - 'credit': -new_balance if new_balance < 0.0 else 0.0, - }) - total_balance = 0.0 - - # Step 4: If the journal entry is not yet balanced, create an open balance. - - if self.company_currency_id.round(total_balance): - counterpart_vals = { - 'name': '%s: %s' % (self.payment_ref, _('Open Balance')), - 'balance': -total_balance, - 'currency_id': self.company_currency_id.id, - } - - partner = self.partner_id or existing_lines.mapped('partner_id')[:1] - if partner: - if self.amount > 0: - open_balance_account = partner.with_company(self.company_id).property_account_receivable_id - else: - open_balance_account = partner.with_company(self.company_id).property_account_payable_id - - counterpart_vals['account_id'] = open_balance_account.id - counterpart_vals['partner_id'] = partner.id - else: - if self.amount > 0: - open_balance_account = self.company_id.partner_id.with_company(self.company_id).property_account_receivable_id - else: - open_balance_account = self.company_id.partner_id.with_company(self.company_id).property_account_payable_id - counterpart_vals['account_id'] = open_balance_account.id - - open_balance_vals = self._prepare_counterpart_move_line_vals(counterpart_vals) - else: - open_balance_vals = None - - return reconciliation_overview, open_balance_vals - - def reconcile(self, lines_vals_list, to_check=False, allow_partial=False): - ''' Perform a reconciliation on the current account.bank.statement.line with some - counterpart account.move.line. - If the statement line entry is not fully balanced after the reconciliation, an open balance will be created - using the partner. - - :param lines_vals_list: A list of python dictionary containing: - 'id': Optional id of an existing account.move.line. - For each line having an 'id', a new line will be created in the current statement line. - 'balance': Optional amount to consider during the reconciliation. If a foreign currency is set on the - counterpart line in the same foreign currency as the statement line, then this amount is - considered as the amount in foreign currency. If not specified, the full balance is taken. - This value must be provided if 'id' is not. - **kwargs: Custom values to be set on the newly created account.move.line. - :param to_check: Mark the current statement line as "to_check" (see field for more details). - :param allow_partial: In case of matching a line having an higher amount, allow creating a partial instead - of an open balance on the statement line. - ''' - self.ensure_one() - liquidity_lines, suspense_lines, other_lines = self._seek_for_lines() - - reconciliation_overview, open_balance_vals = self._prepare_reconciliation( - lines_vals_list, - allow_partial=allow_partial, - ) - - # ==== Manage res.partner.bank ==== - - if self.account_number and self.partner_id and not self.partner_bank_id: - self.partner_bank_id = self._find_or_create_bank_account() - - # ==== Check open balance ==== - - if open_balance_vals: - if not open_balance_vals.get('partner_id'): - raise UserError(_("Unable to create an open balance for a statement line without a partner set.")) - if not open_balance_vals.get('account_id'): - raise UserError(_("Unable to create an open balance for a statement line because the receivable " - "/ payable accounts are missing on the partner.")) - - # ==== Create & reconcile lines on the bank statement line ==== - - to_create_commands = [(0, 0, open_balance_vals)] if open_balance_vals else [] - to_delete_commands = [(2, line.id) for line in suspense_lines + other_lines] - - # Cleanup previous lines. - self.move_id.with_context(check_move_validity=False, skip_account_move_synchronization=True, force_delete=True).write({ - 'line_ids': to_delete_commands + to_create_commands, - 'to_check': to_check, - }) - - line_vals_list = [reconciliation_vals['line_vals'] for reconciliation_vals in reconciliation_overview] - new_lines = self.env['account.move.line'].create(line_vals_list) - new_lines = new_lines.with_context(skip_account_move_synchronization=True) - for reconciliation_vals, line in zip(reconciliation_overview, new_lines): - if reconciliation_vals.get('counterpart_line'): - counterpart_line = reconciliation_vals['counterpart_line'] - else: - continue - - (line + counterpart_line).reconcile() - - # Assign partner if needed (for example, when reconciling a statement - # line with no partner, with an invoice; assign the partner of this invoice) - if not self.partner_id: - rec_overview_partners = set(overview['counterpart_line'].partner_id.id - for overview in reconciliation_overview - if overview.get('counterpart_line')) - if len(rec_overview_partners) == 1 and rec_overview_partners != {False}: - self.line_ids.write({'partner_id': rec_overview_partners.pop()}) - - # Refresh analytic lines. - self.move_id.line_ids.analytic_line_ids.unlink() - self.move_id.line_ids.create_analytic_lines() - # ------------------------------------------------------------------------- # BUSINESS METHODS # ------------------------------------------------------------------------- + def _get_st_line_strings_for_matching(self, allowed_fields=None): + """ Collect the strings that could be used on the statement line to perform some matching. + + :param allowed_fields: A explicit list of fields to consider. + :return: A list of strings. + """ + self.ensure_one() + + def _get_text_value(field_name): + if self._fields[field_name].type == 'html': + return html2plaintext(self[field_name]) + else: + return self[field_name] + + st_line_text_values = [] + if allowed_fields is None or 'payment_ref' in allowed_fields: + value = _get_text_value('payment_ref') + if value: + st_line_text_values.append(value) + if allowed_fields is None or 'narration' in allowed_fields: + value = _get_text_value('narration') + if value: + st_line_text_values.append(value) + if allowed_fields is None or 'ref' in allowed_fields: + value = _get_text_value('ref') + if value: + st_line_text_values.append(value) + return st_line_text_values + + def _get_default_amls_matching_domain(self): + return [ + # Base domain. + ('display_type', 'not in', ('line_section', 'line_note')), + ('move_id.state', '=', 'posted'), + ('company_id', '=', self.company_id.id), + # Reconciliation domain. + ('reconciled', '=', False), + ('account_id.reconcile', '=', True), + # Special domain for payments. + '|', + ('account_id.internal_type', 'not in', ('receivable', 'payable')), + ('payment_id', '=', False), + # Special domain for statement lines. + ('statement_line_id', '!=', self.id), + ] + + def _retrieve_partner(self): + self.ensure_one() + + # Retrieve the partner from the statement line. + if self.partner_id: + return self.partner_id + + # Retrieve the partner from the bank account. + if self.account_number: + account_number_nums = sanitize_account_number(self.account_number) + if account_number_nums: + domain = [('sanitized_acc_number', 'ilike', account_number_nums)] + for extra_domain in ([('company_id', '=', self.company_id.id)], []): + bank_accounts = self.env['res.partner.bank'].search(extra_domain + domain) + if len(bank_accounts.partner_id) == 1: + return bank_accounts.partner_id + + # Retrieve the partner from the partner name. + if self.partner_name: + domain = [ + ('parent_id', '=', False), + ('name', 'ilike', self.partner_name), + ] + for extra_domain in ([('company_id', '=', self.company_id.id)], []): + partner = self.env['res.partner'].search(extra_domain + domain, limit=1) + if partner: + return partner + + # Retrieve the partner from the reconcile models. + rec_models = self.env['account.reconcile.model'].search([ + ('rule_type', '!=', 'writeoff_button'), + ('company_id', '=', self.company_id.id), + ]) + for rec_model in rec_models: + partner = rec_model._get_partner_from_mapping(self) + if partner and rec_model._is_applicable_for(self, partner): + return partner + + # Retrieve the partner from statement line text values. + st_line_text_values = self._get_st_line_strings_for_matching() + unaccent = get_unaccent_wrapper(self._cr) + sub_queries = [] + params = [] + for text_value in st_line_text_values: + if not text_value: + continue + + # Find a partner having a name contained inside the statement line values. + # Take care a partner could contain some special characters in its name that needs to be escaped. + sub_queries.append(rf''' + {unaccent("%s")} ~* ('^' || ( + SELECT STRING_AGG(CONCAT('(?=.*\m', chunk[1], '\M)'), '') + FROM regexp_matches({unaccent('name')}, '\w{{3,}}', 'g') AS chunk + )) + ''') + params.append(text_value) + + if sub_queries: + self.env['res.partner'].flush_model(['company_id', 'name']) + self._cr.execute( + ''' + SELECT id + FROM res_partner + WHERE (company_id IS NULL OR company_id = %s) + AND name IS NOT NULL + AND (''' + ') OR ('.join(sub_queries) + ''') + ''', + [self.company_id.id] + params, + ) + rows = self._cr.fetchall() + if len(rows) == 1: + return self.env['res.partner'].browse(rows[0][0]) + + return self.env['res.partner'] + def _find_or_create_bank_account(self): bank_account = self.env['res.partner.bank'].search([ ('acc_number', '=', self.account_number), diff --git a/addons/account/models/account_journal_dashboard.py b/addons/account/models/account_journal_dashboard.py index d018737f089..ce656e48f01 100644 --- a/addons/account/models/account_journal_dashboard.py +++ b/addons/account/models/account_journal_dashboard.py @@ -486,10 +486,11 @@ class account_journal(models.Model): def to_check_ids(self): self.ensure_one() - domain = self.env['account.move.line']._get_suspense_moves_domain() - domain.append(('journal_id', '=', self.id)) - statement_line_ids = self.env['account.move.line'].search(domain).mapped('statement_line_id') - return statement_line_ids + return self.env['account.bank.statement.line'].search([ + ('journal_id', '=', self.id), + ('move_id.to_check', '=', True), + ('move_id.state', '=', 'posted'), + ]) def _select_action_to_open(self): self.ensure_one() diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 947e9495d4c..1a9a72f1e4d 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -368,9 +368,6 @@ class AccountMove(models.Model): invoice_filter_type_domain = fields.Char(compute='_compute_invoice_filter_type_domain', help="Technical field used to have a dynamic domain on journal / taxes in the form view.") bank_partner_id = fields.Many2one('res.partner', help='Technical field to get the domain on the bank', compute='_compute_bank_partner_id') - invoice_has_matching_suspense_amount = fields.Boolean(compute='_compute_has_matching_suspense_amount', - groups='account.group_account_invoice,account.group_account_readonly', - help="Technical field used to display an alert on invoices if there is at least a matching amount in any supsense account.") tax_lock_date_message = fields.Char( compute='_compute_tax_lock_date_message', help="Technical field used to display a message when the invoice's accounting date is prior of the tax lock date.") @@ -1531,33 +1528,6 @@ class AccountMove(models.Model): move.write({'line_ids': to_write}) - def _get_domain_matching_suspense_moves(self): - self.ensure_one() - domain = self.env['account.move.line']._get_suspense_moves_domain() - domain += ['|', ('partner_id', '=?', self.partner_id.id), ('partner_id', '=', False)] - if self.is_inbound(): - domain.append(('balance', '=', -self.amount_residual)) - else: - domain.append(('balance', '=', self.amount_residual)) - return domain - - def _compute_has_matching_suspense_amount(self): - for r in self: - res = False - if r.state == 'posted' and r.is_invoice() and r.payment_state == 'not_paid': - domain = r._get_domain_matching_suspense_moves() - #there are more than one but less than 5 suspense moves matching the residual amount - if (0 < self.env['account.move.line'].search_count(domain) < 5): - domain2 = [ - ('payment_state', '=', 'not_paid'), - ('state', '=', 'posted'), - ('amount_residual', '=', r.amount_residual), - ('move_type', '=', r.move_type)] - #there are less than 5 other open invoices of the same type with the same residual - if self.env['account.move'].search_count(domain2) < 5: - res = True - r.invoice_has_matching_suspense_amount = res - @api.depends('partner_id', 'invoice_source_email', 'partner_id.name') def _compute_invoice_partner_display_info(self): for move in self: @@ -2904,12 +2874,6 @@ class AccountMove(models.Model): move.to_check = False def button_draft(self): - AccountMoveLine = self.env['account.move.line'] - excluded_move_ids = [] - - if self._context.get('suspense_moves_mode'): - excluded_move_ids = AccountMoveLine.search(AccountMoveLine._get_suspense_moves_domain() + [('move_id', 'in', self.ids)]).mapped('move_id').ids - for move in self: if move in move.line_ids.mapped('full_reconcile_id.exchange_move_id'): raise UserError(_('You cannot reset to draft an exchange difference journal entry.')) @@ -2920,7 +2884,7 @@ class AccountMove(models.Model): # so we also check tax_cash_basis_origin_move_id, which stays unchanged # (we need both, as tax_cash_basis_origin_move_id did not exist in older versions). raise UserError(_('You cannot reset to draft a tax cash basis journal entry.')) - if move.restrict_mode_hash_table and move.state == 'posted' and move.id not in excluded_move_ids: + if move.restrict_mode_hash_table and move.state == 'posted': raise UserError(_('You cannot modify a posted entry of this journal because it is in strict mode.')) # We remove all the analytics entries for this journal move.mapped('line_ids.analytic_line_ids').unlink() @@ -3395,7 +3359,7 @@ class AccountMove(models.Model): class AccountMoveLine(models.Model): _name = "account.move.line" _description = "Journal Item" - _order = "date desc, move_name desc, id" + _order = "date desc, move_name desc, sequence, id" _check_company_auto = True # ==== Business fields ==== @@ -5783,13 +5747,35 @@ class AccountMoveLine(models.Model): action['context'] = ctx return action - @api.model - def _get_suspense_moves_domain(self): - return [ - ('move_id.to_check', '=', True), - ('full_reconcile_id', '=', False), - ('statement_line_id', '!=', False), - ] + def action_open_business_doc_from_aml(self): + self.ensure_one() + + if self.payment_id: + name = _("Payment") + res_model = 'account.payment' + res_id = self.payment_id.id + elif self.statement_line_id: + name = _("Bank Transaction") + res_model = 'account.bank.statement.line' + res_id = self.statement_line_id.id + else: + name = _("Journal Entry") + res_model = 'account.move' + res_id = self.move_id.id + + return { + 'name': name, + 'type': 'ir.actions.act_window', + 'view_mode': 'form', + 'view_id': self.env.ref('account.view_move_form').id, + 'res_model': res_model, + 'res_id': res_id, + 'context': { + 'create': False, + 'delete': False, + }, + 'target': 'current', + } def _get_attachment_domains(self): self.ensure_one() diff --git a/addons/account/models/account_reconcile_model.py b/addons/account/models/account_reconcile_model.py index 343227db1e3..a0db9991492 100644 --- a/addons/account/models/account_reconcile_model.py +++ b/addons/account/models/account_reconcile_model.py @@ -115,6 +115,105 @@ class AccountReconcileModelLine(models.Model): except re.error: raise UserError(_('The regex is not valid')) + def _prepare_aml_vals(self, partner): + """ Prepare a dictionary that will be used later to create a new journal item (account.move.line) for the + given reconcile model line. + + :param partner: The partner to be linked to the journal item. + :return: A python dictionary. + """ + self.ensure_one() + + taxes = self.tax_ids + if taxes and partner: + fiscal_position = self.env['account.fiscal.position']._get_fiscal_position(partner) + if fiscal_position: + taxes = fiscal_position.map_tax(taxes) + + return { + 'name': self.label, + 'account_id': self.account_id.id, + 'partner_id': partner.id, + 'analytic_account_id': self.analytic_account_id.id, + 'analytic_tag_ids': [Command.set(self.analytic_tag_ids.ids)], + 'tax_ids': [Command.set(taxes.ids)], + 'reconcile_model_id': self.model_id.id, + } + + def _apply_in_manual_widget(self, residual_amount_currency, partner, currency): + """ Prepare a dictionary that will be used later to create a new journal item (account.move.line) for the + given reconcile model line used by the manual reconciliation widget. + + Note: 'journal_id' is added to the returned dictionary even if it is a related readonly field. + It's a hack for the manual reconciliation widget. Indeed, a single journal entry will be created for each + journal. + + :param residual_amount_currency: The current balance expressed in the account's currency. + :param partner: The partner to be linked to the journal item. + :param currency: The currency set on the account in the manual reconciliation widget. + :return: A python dictionary. + """ + self.ensure_one() + + if self.amount_type == 'percentage': + amount_currency = currency.round(residual_amount_currency * (self.amount / 100.0)) + elif self.amount_type == 'fixed': + sign = 1 if residual_amount_currency > 0.0 else -1 + amount_currency = currency.round(self.amount * sign) + else: + raise UserError(_("This reconciliation model can't be used in the manual reconciliation widget because its " + "configuration is not adapted")) + + return { + **self._prepare_aml_vals(partner), + 'currency_id': currency.id, + 'amount_currency': amount_currency, + 'journal_id': self.journal_id.id, + } + + def _apply_in_bank_widget(self, residual_amount_currency, partner, st_line): + """ Prepare a dictionary that will be used later to create a new journal item (account.move.line) for the + given reconcile model line used by the bank reconciliation widget. + + :param residual_amount_currency: The current balance expressed in the statement line's currency. + :param partner: The partner to be linked to the journal item. + :param st_line: The statement line mounted inside the bank reconciliation widget. + :return: A python dictionary. + """ + self.ensure_one() + currency = st_line.foreign_currency_id or st_line.journal_id.currency_id or st_line.company_currency_id + + amount_currency = None + if self.amount_type == 'percentage_st_line': + amount_currency = currency.round(residual_amount_currency * (self.amount / 100.0)) + elif self.amount_type == 'regex': + match = re.search(self.amount_string, st_line.payment_ref) + if match: + sign = 1 if residual_amount_currency > 0.0 else -1 + decimal_separator = self.model_id.decimal_separator + try: + extracted_match_group = re.sub(r'[^\d' + decimal_separator + ']', '', match.group(1)) + extracted_balance = float(extracted_match_group.replace(decimal_separator, '.')) + amount_currency = copysign(extracted_balance * sign, residual_amount_currency) + except ValueError: + amount_currency = 0.0 + else: + amount_currency = 0.0 + + if amount_currency is None: + aml_vals = self._apply_in_manual_widget(residual_amount_currency, partner, currency) + else: + aml_vals = { + **self._prepare_aml_vals(partner), + 'currency_id': currency.id, + 'amount_currency': amount_currency, + } + + if not aml_vals['name']: + aml_vals['name'] = st_line.payment_ref + + return aml_vals + class AccountReconcileModel(models.Model): _name = 'account.reconcile.model' @@ -312,7 +411,35 @@ class AccountReconcileModel(models.Model): # RECONCILIATION PROCESS #################################################### - def _get_taxes_move_lines_dict(self, tax, base_line_dict, statement_line=None): + def _apply_lines_for_bank_widget(self, residual_amount_currency, partner, st_line): + """ Apply the reconciliation model lines to the statement line passed as parameter. + + :param residual_amount_currency: The open balance of the statement line in the bank reconciliation widget + expressed in the statement line currency. + :param partner: The partner set on the wizard. + :param st_line: The statement line processed by the bank reconciliation widget. + :return: A list of python dictionaries (one per reconcile model line) representing + the journal items to be created by the current reconcile model. + """ + self.ensure_one() + currency = st_line.foreign_currency_id or st_line.journal_id.currency_id or st_line.company_currency_id + if currency.is_zero(residual_amount_currency): + return [] + + vals_list = [] + for line in self.line_ids: + vals = line._apply_in_bank_widget(residual_amount_currency, partner, st_line) + amount_currency = vals['amount_currency'] + + if currency.is_zero(amount_currency): + continue + + vals_list.append(vals) + residual_amount_currency -= amount_currency + + return vals_list + + 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. @@ -322,10 +449,7 @@ class AccountReconcileModel(models.Model): balance = base_line_dict['balance'] tax_type = tax.type_tax_use - if statement_line: - is_refund = (tax_type == 'sale' and balance > 0) or (tax_type == 'purchase' and balance < 0) - else: - is_refund = (tax_type == 'sale' and balance < 0) or (tax_type == 'purchase' and balance > 0) + is_refund = (tax_type == 'sale' and balance < 0) or (tax_type == 'purchase' and balance > 0) res = tax.compute_all(balance, is_refund=is_refund) @@ -347,6 +471,7 @@ class AccountReconcileModel(models.Model): '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'])], + 'group_tax_id': tax_res['group'].id if tax_res['group'] else False, 'currency_id': False, 'reconcile_model_id': self.id, }) @@ -362,10 +487,9 @@ class AccountReconcileModel(models.Model): 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, partner_id): - ''' 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. + def _get_write_off_move_lines_dict(self, residual_balance, partner_id): + ''' Get move.lines dict corresponding to the reconciliation model's write-off lines. + :param residual_balance: The residual balance of the account on the manual reconciliation widget. :return: A list of dict representing move.lines to be created corresponding to the write-off lines. ''' self.ensure_one() @@ -373,38 +497,12 @@ class AccountReconcileModel(models.Model): if self.rule_type == 'invoice_matching' and (not self.allow_payment_tolerance or self.payment_tolerance_param == 0): return [] - if st_line: - currency = st_line.foreign_currency_id or st_line.currency_id - matched_candidates_values = self._process_matched_candidates_data(st_line) - st_line_residual = matched_candidates_values['balance_sign'] * matched_candidates_values['residual_balance_curr'] - else: - currency = self.company_id.currency_id - - # No statement line - if any(x.amount_type == 'percentage_st_line' for x in self.line_ids): - return [] + currency = self.company_id.currency_id lines_vals_list = [] for line in self.line_ids: if line.amount_type == 'percentage': balance = currency.round(residual_balance * (line.amount / 100.0)) - elif line.amount_type == 'percentage_st_line': - if st_line: - balance = currency.round(st_line_residual * (line.amount / 100.0)) - else: - balance = 0.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_match_group = re.sub(r'[^\d' + self.decimal_separator + ']', '', match.group(1)) - extracted_balance = float(extracted_match_group.replace(self.decimal_separator, '.')) - balance = copysign(extracted_balance * sign, residual_balance) - except ValueError: - balance = 0 - else: - balance = 0 elif line.amount_type == 'fixed': balance = currency.round(line.amount * (1 if residual_balance > 0.0 else -1)) @@ -412,7 +510,7 @@ class AccountReconcileModel(models.Model): continue writeoff_line = { - 'name': line.label or st_line.payment_ref, + 'name': line.label, 'balance': balance, 'debit': balance > 0 and balance or 0, 'credit': balance < 0 and -balance or 0, @@ -438,7 +536,7 @@ class AccountReconcileModel(models.Model): # only allow to set the force_tax_included field if we have one tax selected if line.force_tax_included: taxes = taxes[0].with_context(force_price_include=True) - tax_vals_list = self._get_taxes_move_lines_dict(taxes, writeoff_line, statement_line=st_line) + tax_vals_list = self._get_taxes_move_lines_dict(taxes, writeoff_line) lines_vals_list += tax_vals_list if not line.force_tax_included: for tax_line in tax_vals_list: @@ -450,76 +548,56 @@ class AccountReconcileModel(models.Model): # RECONCILIATION CRITERIA #################################################### - def _apply_rules(self, st_lines, excluded_ids=None, partner_map=None): + def _apply_rules(self, st_line, partner): ''' 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). + the statement line 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. + :param st_line: The statement line to match. + :param partner: The partner to consider. + :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. + * auto_reconcile: A flag indicating if the match is enough significant to auto reconcile the candidates. ''' - # 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_model() - - 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 + if not rec_model._is_applicable_for(st_line, partner): continue - all_model_candidates = rec_model._get_candidates(filtered_st_lines_with_partner, excluded_ids) + if rec_model.rule_type == 'invoice_matching': + rules_map = rec_model._get_invoice_matching_rules_map() + for rule_index in sorted(rules_map.keys()): + for rule_method in rules_map[rule_index]: + candidate_vals = rule_method(st_line, partner) + if not candidate_vals: + continue - 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 candidate_vals.get('amls'): + res = rec_model._get_invoice_matching_amls_result(st_line, partner, candidate_vals) + if res: + return { + **res, + 'model': rec_model, + } + else: + return { + **candidate_vals, + 'model': rec_model, + } - 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 + elif rec_model.rule_type == 'writeoff_suggestion': + return { + 'model': rec_model, + 'status': 'write_off', + 'auto_reconcile': rec_model.auto_reconcile, + } + return {} def _is_applicable_for(self, st_line, partner): """ Returns true iff this reconciliation model can be used to search for matches @@ -555,201 +633,147 @@ class AccountReconcileModel(models.Model): 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. + def _get_invoice_matching_amls_domain(self, st_line, partner): + aml_domain = st_line._get_default_amls_matching_domain() - :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() + if st_line.amount > 0.0: + aml_domain.append(('balance', '>', 0.0)) + else: + aml_domain.append(('balance', '<', 0.0)) - 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), - } + currency = st_line.foreign_currency_id or st_line.currency_id + if self.match_same_currency: + aml_domain.append(('currency_id', '=', currency.id)) - query_generator = treatment_map[self.rule_type] - query, params = query_generator(self) - self._cr.execute(query, params) + if partner: + aml_domain.append(('partner_id', '=', partner.id)) - 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_fields_consideration = [ - (self.match_text_location_label, 'st_line.payment_ref'), - (self.match_text_location_note, 'st_line_move.narration'), - (self.match_text_location_reference, 'st_line_move.ref'), - ] - - no_partner_query = " OR ".join([ - r""" - ( - substring(REGEXP_REPLACE(""" + sql_field + r""", '[^0-9\s]', '', 'g'), '\S(?:.*\S)*') != '' - AND - ( - (""" + self._get_select_communication_flag() + r""") - OR - (""" + self._get_select_payment_reference_flag() + r""") - ) - ) - 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(sql_field) + r""" ~* ('^' || ( - SELECT string_agg(concat('(?=.*\m', chunk[1], '\M)'), '') - FROM regexp_matches(""" + unaccent("aml_partner.name") + r""", '\w{3,}', 'g') AS chunk - )) - ) - """ - for consider_field, sql_field in st_line_fields_consideration - if consider_field - ]) - - if no_partner_query: - st_line_subquery += " AND " + no_partner_query - - 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 + aml_domain.append(('date', '>=', fields.Date.to_string(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) + return aml_domain + + def _get_invoice_matching_st_line_tokens(self, st_line): + """ Parse the textual information from the statement line passed as parameter + in order to extract from it the meaningful information in order to perform the matching. + + :param st_line: A statement line. + :return: A list of tokens, each one being a string. + """ + st_line_text_values = st_line._get_st_line_strings_for_matching(allowed_fields=( + 'payment_ref' if self.match_text_location_label else None, + 'narration' if self.match_text_location_note else None, + 'ref' if self.match_text_location_reference else None, + )) + significant_token_size = 4 + tokens = [] + for text_value in st_line_text_values: + for token in (text_value or '').split(): + # The token is too short to be significant. + if len(token) < significant_token_size: + continue + + formatted_token = ''.join(x for x in token if x.isdecimal()) + + # The token is too short after formatting to be significant. + if len(formatted_token) < significant_token_size: + continue + + tokens.append(formatted_token) + return tokens + + def _get_invoice_matching_amls_candidates(self, st_line, partner): + """ Returns the match candidates for the 'invoice_matching' rule, with respect to the provided parameters. + + :param st_line: A statement line. + :param partner: The partner associated to the statement line. + """ + assert self.rule_type == 'invoice_matching' + self.env['account.move'].flush_model() + self.env['account.move.line'].flush_model() if self.matching_order == 'new_first': - query += ' ORDER BY aml_date_maturity DESC, aml_id DESC' + order_by = 'sub.date_maturity DESC, sub.date DESC, sub.id DESC' else: - query += ' ORDER BY aml_date_maturity ASC, aml_id ASC' + order_by = 'sub.date_maturity ASC, sub.date ASC, sub.id ASC' - return query, params + aml_domain = self._get_invoice_matching_amls_domain(st_line, partner) + query = self.env['account.move.line']._where_calc(aml_domain) + tables, where_clause, where_params = query.get_sql() - 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'] + tokens = self._get_invoice_matching_st_line_tokens(st_line) + if tokens: + sub_queries = [] + for table_alias, field in ( + ('account_move_line', 'name'), + ('account_move_line__move_id', 'name'), + ('account_move_line__move_id', 'ref'), + ): + sub_queries.append(rf''' + SELECT + account_move_line.id, + account_move_line.date, + account_move_line.date_maturity, + UNNEST( + REGEXP_SPLIT_TO_ARRAY( + SUBSTRING( + REGEXP_REPLACE({table_alias}.{field}, '[^0-9\s]', '', 'g'), + '\S(?:.*\S)*' + ), + '\s+' + ) + ) AS token + FROM {tables} + WHERE {where_clause} AND {table_alias}.{field} IS NOT NULL + ''') - 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 + self._cr.execute( + ''' + SELECT + sub.id, + COUNT(*) AS nb_match + FROM (''' + ' UNION ALL '.join(sub_queries) + ''') AS sub + WHERE sub.token IN %s + GROUP BY sub.date_maturity, sub.date, sub.id + HAVING COUNT(*) > 0 + ORDER BY nb_match DESC, ''' + order_by + ''' + ''', + (where_params * 3) + [tuple(tokens)], ) - ) + candidate_ids = [r[0] for r in self._cr.fetchall()] + if candidate_ids: + return { + 'allow_auto_reconcile': True, + 'amls': self.env['account.move.line'].browse(candidate_ids), + } + + # Search without any matching based on textual information. + if partner: + + if self.matching_order == 'new_first': + order = 'date_maturity DESC, date DESC, id DESC' + else: + order = 'date_maturity ASC, date ASC, id ASC' + + amls = self.env['account.move.line'].search(aml_domain, order=order) + if amls: + return { + 'allow_auto_reconcile': False, + 'amls': amls, + } + + def _get_invoice_matching_rules_map(self): + """ Get a mapping that could be overridden in others modules. + + :return: a mapping where: + * priority_order: Defines in which order the rules will be evaluated, the lowest comes first. + This is extremely important since the algorithm stops when a rule returns some candidates. + * rule: Method taking as parameters and returning the candidates journal items found. + """ + rules_map = defaultdict(list) + rules_map[10].append(self._get_invoice_matching_amls_candidates) + return rules_map def _get_partner_from_mapping(self, st_line): """Find partner with mapping defined on model. @@ -778,224 +802,80 @@ class AccountReconcileModel(models.Model): 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) - + def _get_invoice_matching_amls_result(self, st_line, partner, candidate_vals): st_line_currency = st_line.foreign_currency_id or st_line.currency_id - candidate_currencies = set(candidate['aml_currency_id'] for candidate in candidates) - kept_candidates = candidates - if candidate_currencies == {st_line_currency.id}: - kept_candidates = [] - sum_kept_candidates = 0 - for candidate in candidates: - candidate_residual = candidate['aml_amount_residual_currency'] - if st_line_currency.compare_amounts(candidate_residual, -st_line.amount_residual) == 0: + amls = candidate_vals['amls'] + if amls.currency_id == st_line_currency: + st_line_amount = st_line._prepare_move_line_default_vals()[1]['amount_currency'] + sign = 1 if st_line_amount > 0.0 else -1 + + kepts_amls = self.env['account.move.line'] + sum_aml_residual = 0.0 + for aml in amls: + + if st_line_currency.compare_amounts(st_line_amount, -aml.amount_residual_currency) == 0: # Special case: the amounts are the same, submit the line directly. - kept_candidates = [candidate] + kepts_amls = aml break - elif st_line_currency.compare_amounts(abs(sum_kept_candidates), abs(st_line.amount_residual)) < 0: - # Candidates' and statement line's balances have the same sign, thanks to _get_invoice_matching_query. - # We hence can compare their absolute value without any issue. + elif st_line_currency.compare_amounts(sign * (st_line_amount + sum_aml_residual), 0.0) > 0: # Here, we still have room for other candidates ; so we add the current one to the list we keep. # Then, we continue iterating, even if there is no room anymore, just in case one of the following candidates # is an exact match, which would then be preferred on the current candidates. - kept_candidates.append(candidate) - sum_kept_candidates += candidate_residual - - # It is possible kept_candidates now contain less different priorities; update them - kept_candidates_by_priority = self._sort_reconciliation_candidates_by_priority(kept_candidates, aml_ids_to_exclude, reconciled_amls_ids) - priorities = set(kept_candidates_by_priority.keys()) + kepts_amls |= aml + sum_aml_residual += aml.amount_residual_currency + else: + kepts_amls = amls # We check the amount criteria of the reconciliation model, and select the # kept_candidates if they pass the verification. - matched_candidates_values = self._process_matched_candidates_data(st_line, kept_candidates) - status = self._check_rule_propositions(matched_candidates_values) + status = self._check_rule_propositions(st_line, kepts_amls) if 'rejected' in status: - rslt = None - else: - rslt = { - 'model': self, - 'aml_ids': [candidate['aml_id'] for candidate in kept_candidates], - } - new_treated_aml_ids = set(rslt['aml_ids']) + return None - # Create write-off lines (in company's currency). - if 'allow_write_off' in status: - residual_balance_after_rec = matched_candidates_values['residual_balance_curr'] + matched_candidates_values['candidates_balance_curr'] - writeoff_vals_list = self._get_write_off_move_lines_dict( - st_line, - matched_candidates_values['balance_sign'] * residual_balance_after_rec, - partner.id, - ) - if writeoff_vals_list: - rslt['status'] = 'write_off' - rslt['write_off_vals'] = writeoff_vals_list - else: - writeoff_vals_list = [] + rslt = {'amls': kepts_amls} - # Reconcile. - if 'allow_auto_reconcile' in status: + if 'allow_write_off' in status and self.line_ids: + rslt['status'] = 'write_off' - # Process auto-reconciliation. We only do that for the first two priorities, if they are not matched elsewhere. - aml_ids = [candidate['aml_id'] for candidate in kept_candidates] - lines_vals_list = [{'id': aml_id} for aml_id in aml_ids] + if 'allow_auto_reconcile' in status and candidate_vals['allow_auto_reconcile'] and self.auto_reconcile: + rslt['auto_reconcile'] = True - if lines_vals_list and priorities & {1, 3} and self.auto_reconcile: + return rslt - # Ensure this will not raise an error if case of missing account to create an open balance. - dummy, open_balance_vals = st_line._prepare_reconciliation(lines_vals_list + writeoff_vals_list) - - if not open_balance_vals or open_balance_vals.get('account_id'): - - if not st_line.partner_id and partner: - st_line.partner_id = partner - - st_line.reconcile(lines_vals_list + writeoff_vals_list, allow_partial=True) - - rslt['status'] = 'reconciled' - rslt['reconciled_lines'] = st_line.line_ids - new_reconciled_aml_ids = new_treated_aml_ids - - return rslt, new_reconciled_aml_ids, new_treated_aml_ids - - def _process_matched_candidates_data(self, statement_line, candidates=None): - """ Simulate the reconciliation of the statement line with the candidates and - compute some useful data to perform all the matching rules logic. - - :param statement_line: An account.bank.statement.line record. - :param candidates: Fetched account.move.lines from query (dict). - :return: A python dict containing: - * currency: The currency of the transaction. - * statement_line: The statement line matching the candidates. - * candidates: Fetched account.move.lines from query (dict). - * reconciliation_overview: The computed reconciliation from '_prepare_reconciliation'. - * open_balance_vals: The open balance returned by '_prepare_reconciliation'. - * balance_sign: The sign applied to the balance to make amounts always positive. - * residual_balance: The residual balance of the statement line before reconciling anything, - always positive and expressed in company's currency. - * candidates_balance: The balance of candidates lines expressed in company's currency. - * residual_balance_curr: The residual balance of the statement line before reconciling anything, - always positive and expressed in transaction's currency. - * candidates_balance_curr: The balance of candidates lines expressed in transaction's currency. - """ - candidates = candidates or [] - - 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]) - - # Compute 'residual_balance', the remaining amount to reconcile of the statement line expressed in the - # transaction currency. - liquidity_lines, suspense_lines, dummy = statement_line._seek_for_lines() - if statement_line.to_check: - stl_residual_balance = -liquidity_lines.balance - stl_residual_balance_curr = -liquidity_lines.amount_currency - elif suspense_lines.account_id.reconcile: - stl_residual_balance = sum(suspense_lines.mapped('amount_residual')) - stl_residual_balance_curr = sum(suspense_lines.mapped('amount_residual_currency')) - else: - stl_residual_balance = sum(suspense_lines.mapped('balance')) - stl_residual_balance_curr = sum(suspense_lines.mapped('amount_currency')) - - # Compute 'reconciled_balance', the total reconciled amount to be reconciled by the candidates. - candidates_balance = 0.0 - candidates_balance_curr = 0.0 - for reconciliation_vals in reconciliation_overview: - line_vals = reconciliation_vals['line_vals'] - candidates_balance -= line_vals['debit'] - line_vals['credit'] - if line_vals['currency_id']: - candidates_balance_curr -= line_vals['amount_currency'] - else: - candidates_balance_curr -= line_vals['debit'] - line_vals['credit'] - - # Sign amount to ease computation. Multiplying any amount from the statement line makes it positive. - balance_sign = 1 if stl_residual_balance > 0.0 else -1 - - return { - 'currency': statement_line.foreign_currency_id or statement_line.currency_id, - 'statement_line': statement_line, - 'candidates': candidates, - 'reconciliation_overview': reconciliation_overview, - 'open_balance_vals': open_balance_vals, - 'balance_sign': balance_sign, - 'residual_balance': balance_sign * stl_residual_balance, - 'candidates_balance': balance_sign * candidates_balance, - 'residual_balance_curr': balance_sign * stl_residual_balance_curr, - 'candidates_balance_curr': balance_sign * candidates_balance_curr, - } - - def _check_rule_propositions(self, matched_candidates_values): + def _check_rule_propositions(self, st_line, amls): """ Check restrictions that can't be handled for each move.line separately. Note: Only used by models having a type equals to 'invoice_matching'. - :param matched_candidates_values: The values computed by '_process_matched_candidates_data'. + :param st_line: The statement line. + :param amls: The candidates account.move.line. :return: A string representing what to do with the candidates: * rejected: Reject candidates. * allow_write_off: Allow to generate the write-off from the reconcile model lines if specified. * allow_auto_reconcile: Allow to automatically reconcile entries if 'auto_validate' is enabled. """ - candidates = matched_candidates_values['candidates'] - currency = matched_candidates_values['currency'] - if not self.allow_payment_tolerance: return {'allow_write_off', 'allow_auto_reconcile'} - if not candidates: + if not amls: return {'rejected'} + currency = st_line.foreign_currency_id or st_line.currency_id + st_line_amount_curr = st_line._prepare_move_line_default_vals()[1]['amount_currency'] + amls_amount_curr = sum( + st_line._prepare_counterpart_amounts_using_st_line_rate(aml.currency_id, aml.amount_residual, aml.amount_residual_currency)['amount_currency'] + for aml in amls + ) + sign = 1 if st_line_amount_curr > 0.0 else -1 + amount_curr_after_rec = sign * (amls_amount_curr + st_line_amount_curr) + # The statement line will be fully reconciled. - residual_balance_after_rec = matched_candidates_values['residual_balance_curr'] + matched_candidates_values['candidates_balance_curr'] - if currency.is_zero(residual_balance_after_rec): + if currency.is_zero(amount_curr_after_rec): return {'allow_auto_reconcile'} # The payment amount is higher than the sum of invoices. # In that case, don't check the tolerance and don't try to generate any write-off. - if residual_balance_after_rec > 0.0: + if amount_curr_after_rec > 0.0: return {'allow_auto_reconcile'} # No tolerance, reject the candidates. @@ -1004,104 +884,12 @@ class AccountReconcileModel(models.Model): # If the tolerance is expressed as a fixed amount, check the residual payment amount doesn't exceed the # tolerance. - if self.payment_tolerance_type == 'fixed_amount' and -residual_balance_after_rec <= self.payment_tolerance_param: + if self.payment_tolerance_type == 'fixed_amount' and -amount_curr_after_rec <= self.payment_tolerance_param: return {'allow_write_off', 'allow_auto_reconcile'} # The tolerance is expressed as a percentage between 0 and 100.0. - reconciled_percentage_left = (residual_balance_after_rec / matched_candidates_values['candidates_balance_curr']) * 100.0 + reconciled_percentage_left = (abs(amount_curr_after_rec / amls_amount_curr)) * 100.0 if self.payment_tolerance_type == 'percentage' and reconciled_percentage_left <= self.payment_tolerance_param: return {'allow_write_off', 'allow_auto_reconcile'} return {'rejected'} - - 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. - matched_candidates_values = self._process_matched_candidates_data(st_line) - residual_balance_after_rec = matched_candidates_values['residual_balance_curr'] + matched_candidates_values['candidates_balance_curr'] - writeoff_vals_list = self._get_write_off_move_lines_dict( - st_line, - matched_candidates_values['balance_sign'] * residual_balance_after_rec, - partner.id, - ) - - rslt = { - 'model': self, - 'status': 'write_off', - 'aml_ids': [], - 'write_off_vals': writeoff_vals_list, - } - - # Process auto-reconciliation. - if writeoff_vals_list and self.auto_reconcile: - if not st_line.partner_id and partner: - st_line.partner_id = partner - - st_line.reconcile(writeoff_vals_list) - rslt['status'] = 'reconciled' - rslt['reconciled_lines'] = st_line.line_ids - - return rslt diff --git a/addons/account/models/company.py b/addons/account/models/company.py index b8471779832..4b122e1ef97 100644 --- a/addons/account/models/company.py +++ b/addons/account/models/company.py @@ -254,6 +254,32 @@ class ResCompany(models.Model): if account.code.startswith(old_code): account.write({'code': self.get_new_account_code(account.code, old_code, new_code)}) + def _get_fiscalyear_lock_statement_lines_redirect_action(self, unreconciled_statement_lines): + """ Get the action redirecting to the statement lines that are not already reconciled when setting a fiscal + year lock date. + + :param unreconciled_statement_lines: The statement lines. + :return: A dictionary representing a window action. + """ + statements = unreconciled_statement_lines.statement_id + action = { + 'name': _("Unreconciled Statements"), + 'type': 'ir.actions.act_window', + 'res_model': 'account.bank.statement', + 'context': {'create': False}, + } + if len(statements) == 1: + action.update({ + 'view_mode': 'form', + 'res_id': statements.id, + }) + else: + action.update({ + 'view_mode': 'list,form', + 'domain': [('id', 'in', statements.ids)], + }) + return action + def _validate_fiscalyear_lock(self, values): if values.get('fiscalyear_lock_date'): @@ -283,11 +309,7 @@ class ResCompany(models.Model): if unreconciled_statement_lines: error_msg = _("There are still unreconciled bank statement lines in the period you want to lock." "You should either reconcile or delete them.") - action_error = { - 'type': 'ir.actions.client', - 'tag': 'bank_statement_reconciliation_view', - 'context': {'statement_line_ids': unreconciled_statement_lines.ids, 'company_ids': self.ids}, - } + action_error = self._get_fiscalyear_lock_statement_lines_redirect_action(unreconciled_statement_lines) raise RedirectWarning(error_msg, action_error, _('Show Unreconciled Bank Statement Line')) def _get_user_fiscal_lock_date(self): diff --git a/addons/account/tests/test_account_bank_statement.py b/addons/account/tests/test_account_bank_statement.py index 6d561152ba1..66ff7715f88 100644 --- a/addons/account/tests/test_account_bank_statement.py +++ b/addons/account/tests/test_account_bank_statement.py @@ -3,9 +3,7 @@ from odoo.addons.account.tests.common import AccountTestInvoicingCommon from odoo.tests import tagged from odoo.tests.common import Form from odoo.exceptions import ValidationError, UserError -from odoo import fields - -from unittest.mock import patch +from odoo import fields, Command class TestAccountBankStatementCommon(AccountTestInvoicingCommon): @@ -824,622 +822,54 @@ class TestAccountBankStatementLine(TestAccountBankStatementCommon): }, ]) - # ------------------------------------------------------------------------- - # TESTS about reconciliation: - # - Test '_prepare_counterpart_move_line_vals': one test for each case. - # - Test 'reconcile': 3 cases: - # - Open-balance in debit. - # - Open-balance in credit. - # - No open-balance. - # - Test 'button_undo_reconciliation'. - # ------------------------------------------------------------------------- + def test_prepare_counterpart_amounts_using_st_line_rate(self): - def _test_statement_line_reconciliation( - self, - journal, - amount, amount_currency, counterpart_amount, - journal_currency, foreign_currency, counterpart_currency, - expected_liquidity_values, expected_counterpart_values): - ''' Test the reconciliation of a statement line. - :param journal: The account.journal record that will be set on the statement line. - :param amount: The amount in journal's currency. - :param amount_currency: The amount in the foreign currency. - :param counterpart_amount: The amount of the invoice to reconcile. - :param journal_currency: The journal's currency as a res.currency record. - :param foreign_currency: The foreign currency as a res.currency record. - :param counterpart_currency: The invoice currency as a res.currency record. - :param expected_liquidity_values: The expected account.move.line values for the liquidity line. - :param expected_counterpart_values: The expected account.move.line values for the counterpart line. - ''' - if journal_currency: - journal.currency_id = journal_currency.id + def assertAppliedRate( + journal_currency, foreign_currency, aml_currency, + amount, amount_currency, aml_amount_currency, aml_balance, + expected_amount_currency, expected_balance, + ): + journal = self.bank_journal_1.copy() + journal.currency_id = journal_currency - statement = self.env['account.bank.statement'].create({ - 'name': 'test_statement', - 'date': '2019-01-01', - 'journal_id': journal.id, - 'line_ids': [ - (0, 0, { - 'date': '2019-01-01', - 'payment_ref': 'line_1', - 'partner_id': self.partner_a.id, - 'foreign_currency_id': foreign_currency and foreign_currency.id, - 'amount': amount, - 'amount_currency': amount_currency, - }), - ], - }) - statement_line = statement.line_ids - - # - There is 3 flows to check: - # * The invoice will fully reconcile the statement line. - # * The invoice will partially reconcile the statement line and leads to an open balance in debit. - # * The invoice will partially reconcile the statement line and leads to an open balance in credit. - # - The dates are different to be sure the reconciliation will preserve the conversion rate bank side. - move_type = 'out_invoice' if counterpart_amount < 0.0 else 'in_invoice' - - test_invoices = self.env['account.move'].create([ - { - 'move_type': move_type, - 'invoice_date': fields.Date.from_string('2016-01-01'), - 'date': fields.Date.from_string('2016-01-01'), - 'partner_id': self.partner_a.id, - 'currency_id': counterpart_currency.id, - 'invoice_line_ids': [ - (0, None, { - 'name': 'counterpart line, same amount', - 'account_id': self.company_data['default_account_revenue'].id, - 'quantity': 1, - 'price_unit': abs(counterpart_amount), + statement = self.env['account.bank.statement'].create({ + 'name': 'test_statement', + 'date': '2019-01-01', + 'journal_id': journal.id, + 'line_ids': [ + Command.create({ + 'date': '2019-01-01', + 'payment_ref': 'test_prepare_counterpart_amounts_using_st_line_rate', + 'foreign_currency_id': foreign_currency.id if foreign_currency != journal_currency else None, + 'amount': amount, + 'amount_currency': amount_currency if foreign_currency != journal_currency else 0.0, }), ], - }, - { - 'move_type': move_type, - 'invoice_date': fields.Date.from_string('2016-01-01'), - 'date': fields.Date.from_string('2016-01-01'), - 'partner_id': self.partner_a.id, - 'currency_id': counterpart_currency.id, - 'invoice_line_ids': [ - (0, None, { - 'name': 'counterpart line, lower amount', - 'account_id': self.company_data['default_account_revenue'].id, - 'quantity': 1, - 'price_unit': abs(counterpart_amount / 2), - }), - ], - }, - { - 'move_type': move_type, - 'invoice_date': fields.Date.from_string('2016-01-01'), - 'date': fields.Date.from_string('2016-01-01'), - 'partner_id': self.partner_a.id, - 'currency_id': counterpart_currency.id, - 'invoice_line_ids': [ - (0, None, { - 'name': 'counterpart line, bigger amount', - 'account_id': self.company_data['default_account_revenue'].id, - 'quantity': 1, - 'price_unit': abs(counterpart_amount * 2), - }), - ], - }, - ]) - test_invoices.action_post() - statement.button_post() - counterpart_lines = test_invoices.mapped('line_ids').filtered(lambda line: line.account_internal_type in ('receivable', 'payable')) + }) + statement_line = statement.line_ids - # Check the full reconciliation. - statement_line.reconcile([{'id': counterpart_lines[0].id}]) - liquidity_lines, suspense_lines, other_lines = statement_line._seek_for_lines() - self.assertRecordValues(liquidity_lines, [expected_liquidity_values]) - self.assertRecordValues(other_lines, [expected_counterpart_values]) + res = statement_line._prepare_counterpart_amounts_using_st_line_rate(aml_currency, -aml_balance, -aml_amount_currency) + self.assertAlmostEqual(res['amount_currency'], expected_amount_currency) + self.assertAlmostEqual(res['balance'], expected_balance) - # Check the reconciliation with partial lower amount. - statement_line.button_undo_reconciliation() - statement_line.reconcile([{'id': counterpart_lines[1].id}]) - liquidity_lines, suspense_lines, other_lines = statement_line._seek_for_lines() - self.assertRecordValues(liquidity_lines, [expected_liquidity_values]) - self.assertRecordValues(other_lines.sorted('balance', reverse=amount < 0.0), [ - { - **expected_counterpart_values, - 'debit': expected_counterpart_values.get('debit', 0.0) / 2, - 'credit': expected_counterpart_values.get('credit', 0.0) / 2, - 'amount_currency': expected_counterpart_values.get('amount_currency', 0.0) / 2, - }, - { - 'debit': expected_counterpart_values.get('debit', 0.0) / 2, - 'credit': expected_counterpart_values.get('credit', 0.0) / 2, - 'amount_currency': expected_counterpart_values.get('amount_currency', 0.0) / 2, - 'currency_id': expected_counterpart_values.get('currency_id'), - }, - ]) - - # Check the reconciliation with partial higher amount. - statement_line.button_undo_reconciliation() - statement_line.reconcile([{'id': counterpart_lines[2].id}]) - liquidity_lines, suspense_lines, other_lines = statement_line._seek_for_lines() - self.assertRecordValues(liquidity_lines, [expected_liquidity_values]) - self.assertRecordValues(other_lines.sorted('balance', reverse=amount < 0.0), [ - { - **expected_counterpart_values, - 'debit': expected_counterpart_values.get('debit', 0.0) * 2, - 'credit': expected_counterpart_values.get('credit', 0.0) * 2, - 'amount_currency': expected_counterpart_values.get('amount_currency', 0.0) * 2, - }, - { - 'debit': expected_counterpart_values.get('credit', 0.0), - 'credit': expected_counterpart_values.get('debit', 0.0), - 'amount_currency': -expected_counterpart_values.get('amount_currency', 0.0), - 'currency_id': expected_counterpart_values.get('currency_id'), - }, - ]) - - # Make sure the statement line is still correct. - self.assertRecordValues(statement_line, [{ - 'amount': amount, - 'amount_currency': amount_currency, - }]) - - def _test_reconciliation_customer_and_supplier_flows( - self, - amount, amount_currency, counterpart_amount, - journal_currency, foreign_currency, counterpart_currency, - expected_liquidity_values, expected_counterpart_values): - ''' Test '_test_statement_line_reconciliation' using the customer (positive amounts) - & the supplier flow (negative amounts). - :param amount: The amount in journal's currency. - :param amount_currency: The amount in the foreign currency. - :param counterpart_amount: The amount of the invoice to reconcile. - :param journal_currency: The journal's currency as a res.currency record. - :param foreign_currency: The foreign currency as a res.currency record. - :param counterpart_currency: The invoice currency as a res.currency record. - :param expected_liquidity_values: The expected account.move.line values for the liquidity line. - :param expected_counterpart_values: The expected account.move.line values for the counterpart line. - ''' - - # Check the full process with positive amount (customer process). - self._test_statement_line_reconciliation( - self.bank_journal_2, - amount, amount_currency, counterpart_amount, - journal_currency, foreign_currency, counterpart_currency, - expected_liquidity_values, - expected_counterpart_values, - ) - - # Check the full process with negative amount (supplier process). - self._test_statement_line_reconciliation( - self.bank_journal_3, - -amount, -amount_currency, -counterpart_amount, - journal_currency, foreign_currency, counterpart_currency, - { - **expected_liquidity_values, - 'debit': expected_liquidity_values.get('credit', 0.0), - 'credit': expected_liquidity_values.get('debit', 0.0), - 'amount_currency': -expected_liquidity_values.get('amount_currency', 0.0), - }, - { - **expected_counterpart_values, - 'debit': expected_counterpart_values.get('credit', 0.0), - 'credit': expected_counterpart_values.get('debit', 0.0), - 'amount_currency': -expected_counterpart_values.get('amount_currency', 0.0), - }, - ) - - def test_reconciliation_journal_curr_2_statement_curr_3_counterpart_curr_3(self): - self._test_reconciliation_customer_and_supplier_flows( - 80.0, 120.0, -120.0, - self.currency_2, self.currency_3, self.currency_3, - {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, - {'debit': 0.0, 'credit': 40.0, 'amount_currency': -120.0, 'currency_id': self.currency_3.id}, - ) - - def test_reconciliation_journal_curr_2_statement_curr_1_counterpart_curr_2(self): - self._test_reconciliation_customer_and_supplier_flows( - 120.0, 80.0, -120.0, - self.currency_2, self.currency_1, self.currency_2, - {'debit': 80.0, 'credit': 0.0, 'amount_currency': 120.0, 'currency_id': self.currency_2.id}, - {'debit': 0.0, 'credit': 80.0, 'amount_currency': -80.0, 'currency_id': self.currency_1.id}, - ) - - def test_reconciliation_journal_curr_2_statement_curr_3_counterpart_curr_2(self): - self._test_reconciliation_customer_and_supplier_flows( - 80.0, 120.0, -80.0, - self.currency_2, self.currency_3, self.currency_2, - {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, - {'debit': 0.0, 'credit': 40.0, 'amount_currency': -120.0, 'currency_id': self.currency_3.id}, - ) - - def test_reconciliation_journal_curr_2_statement_curr_3_counterpart_curr_4(self): - self._test_reconciliation_customer_and_supplier_flows( - 80.0, 120.0, -480.0, - self.currency_2, self.currency_3, self.currency_4, - {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, - {'debit': 0.0, 'credit': 40.0, 'amount_currency': -120.0, 'currency_id': self.currency_3.id}, - ) - - def test_reconciliation_journal_curr_1_statement_curr_2_counterpart_curr_2(self): - self._test_reconciliation_customer_and_supplier_flows( - # pylint: disable=C0326 - 80.0, 120.0, -120.0, - self.currency_1, self.currency_2, self.currency_2, - {'debit': 80.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_1.id}, - {'debit': 0.0, 'credit': 80.0, 'amount_currency': -120.0, 'currency_id': self.currency_2.id}, - ) - - def test_reconciliation_journal_curr_1_statement_curr_2_counterpart_curr_3(self): - self._test_reconciliation_customer_and_supplier_flows( - # pylint: disable=C0326 - 80.0, 120.0, -480.0, - self.currency_1, self.currency_2, self.currency_3, - {'debit': 80.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_1.id}, - {'debit': 0.0, 'credit': 80.0, 'amount_currency': -120.0, 'currency_id': self.currency_2.id}, - ) - - def test_reconciliation_journal_curr_2_statement_false_counterpart_curr_2(self): - self._test_reconciliation_customer_and_supplier_flows( - 80.0, 0.0, -80.0, - self.currency_2, False, self.currency_2, - {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, - {'debit': 0.0, 'credit': 40.0, 'amount_currency': -80.0, 'currency_id': self.currency_2.id}, - ) - - def test_reconciliation_journal_curr_2_statement_false_counterpart_curr_3(self): - self._test_reconciliation_customer_and_supplier_flows( - 80.0, 0.0, -240.0, - self.currency_2, False, self.currency_3, - {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, - {'debit': 0.0, 'credit': 40.0, 'amount_currency': -80.0, 'currency_id': self.currency_2.id}, - ) - - def test_reconciliation_journal_curr_1_statement_false_counterpart_curr_3(self): - self._test_reconciliation_customer_and_supplier_flows( - 80.0, 0.0, -480.0, - self.currency_1, False, self.currency_3, - {'debit': 80.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_1.id}, - {'debit': 0.0, 'credit': 80.0, 'amount_currency': -80.0, 'currency_id': self.currency_1.id}, - ) - - def test_reconciliation_journal_curr_2_statement_curr_1_counterpart_curr_1(self): - self._test_reconciliation_customer_and_supplier_flows( - 120.0, 80.0, -80.0, - self.currency_2, self.currency_1, self.currency_1, - {'debit': 80.0, 'credit': 0.0, 'amount_currency': 120.0, 'currency_id': self.currency_2.id}, - {'debit': 0.0, 'credit': 80.0, 'amount_currency': -80.0, 'currency_id': self.currency_1.id}, - ) - - def test_reconciliation_journal_curr_2_statement_curr_3_counterpart_curr_1(self): - self._test_reconciliation_customer_and_supplier_flows( - 80.0, 120.0, -40.0, - self.currency_2, self.currency_3, self.currency_1, - {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, - {'debit': 0.0, 'credit': 40.0, 'amount_currency': -120.0, 'currency_id': self.currency_3.id}, - ) - - def test_reconciliation_journal_curr_1_statement_curr_2_counterpart_curr_1(self): - self._test_reconciliation_customer_and_supplier_flows( - # pylint: disable=C0326 - 80.0, 120.0, -80.0, - self.currency_1, self.currency_2, self.currency_1, - {'debit': 80.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_1.id}, - {'debit': 0.0, 'credit': 80.0, 'amount_currency': -120.0, 'currency_id': self.currency_2.id}, - ) - - def test_reconciliation_journal_curr_2_statement_false_counterpart_curr_1(self): - self._test_reconciliation_customer_and_supplier_flows( - 80.0, 0.0, -40.0, - self.currency_2, False, self.currency_1, - {'debit': 40.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_2.id}, - {'debit': 0.0, 'credit': 40.0, 'amount_currency': -80.0, 'currency_id': self.currency_2.id}, - ) - - def test_reconciliation_journal_curr_1_statement_false_counterpart_curr_1(self): - self._test_reconciliation_customer_and_supplier_flows( - 80.0, 0.0, -80.0, - self.currency_1, False, self.currency_1, - {'debit': 80.0, 'credit': 0.0, 'amount_currency': 80.0, 'currency_id': self.currency_1.id}, - {'debit': 0.0, 'credit': 80.0, 'amount_currency': -80.0, 'currency_id': self.currency_1.id}, - ) - - def test_reconciliation_move_draft(self): - ''' Ensures that the account move of a reconciled statement cannot be set to draft - ''' - statement = self.env['account.bank.statement'].create({ - 'name': 'test_statement', - 'date': '2019-01-01', - 'journal_id': self.bank_journal_1.id, - 'line_ids': [ - (0, 0, { - 'date': '2019-01-01', - 'payment_ref': 'line_1', - 'partner_id': self.partner_a.id, - 'amount': 100.0, - }), - ], - }) - - statement.button_post() - statement.line_ids[0].reconcile([{'name': 'test', 'account_id': self.company_data['default_account_revenue'].id, 'balance': -100}]) - - statement.button_validate_or_action() - account_move = statement.line_ids.move_id - - with self.assertRaises(UserError): - account_move.button_draft() - - def test_reconciliation_statement_line_state(self): - ''' Test the reconciliation on the bank statement line with a foreign currency on the journal: - - Ensure the statement line is_reconciled field is well computed. - - Ensure the reconciliation is working well when dealing with a foreign currency at different dates. - - Ensure the reconciliation can be undo. - - Ensure the reconciliation is still possible with to_check. - ''' - self.statement.button_post() - - receivable_acc_1 = self.company_data['default_account_receivable'] - receivable_acc_2 = self.copy_account(self.company_data['default_account_receivable']) - payment_account = self.bank_journal_1.company_id.account_journal_payment_debit_account_id - random_acc_1 = self.company_data['default_account_revenue'] - random_acc_2 = self.copy_account(self.company_data['default_account_revenue']) - test_move = self.env['account.move'].create({ - 'move_type': 'entry', - 'date': fields.Date.from_string('2016-01-01'), - 'line_ids': [ - (0, None, { - 'name': 'counterpart of the whole move', - 'account_id': random_acc_1.id, - 'debit': 0.0, - 'credit': 1030.0, - }), - (0, None, { - 'name': 'test line 1 - receivable account', - 'account_id': receivable_acc_1.id, - 'currency_id': self.currency_2.id, - 'debit': 500.0, - 'credit': 0.0, - 'amount_currency': 1500.0, - }), - (0, None, { - 'name': 'test line 2 - another receivable account', - 'account_id': receivable_acc_2.id, - 'currency_id': self.currency_2.id, - 'debit': 500.0, - 'credit': 0.0, - 'amount_currency': 1500.0, - }), - (0, None, { - 'name': 'test line 3 - payment transfer account', - 'account_id': payment_account.id, - 'currency_id': self.currency_2.id, - 'debit': 30.0, - 'credit': 0.0, - 'amount_currency': 90.0, - }), - ] - }) - test_move.action_post() - - test_line_1 = test_move.line_ids.filtered(lambda line: line.account_id == receivable_acc_1) - test_line_2 = test_move.line_ids.filtered(lambda line: line.account_id == receivable_acc_2) - test_line_3 = test_move.line_ids.filtered(lambda line: line.account_id == payment_account) - self.statement_line.reconcile([ - # test line 1 - # Will reconcile 300.0 in balance, 600.0 in amount_currency. - {'id': test_line_1.id, 'balance': -600.0}, - # test line 2 - # Will reconcile 250.0 in balance, 500.0 in amount_currency. - {'id': test_line_2.id, 'balance': -500.0}, - # test line 3 - # Will reconcile 30.0 in balance, 90.0 in amount_currency. - {'id': test_line_3.id}, - # test line 4 - # Will reconcile 50.0 in balance, 100.0 in amount_currency. - {'name': 'whatever', 'account_id': random_acc_1.id, 'balance': -100.0}, - ]) - - self.assertBankStatementLine(self.statement_line, { - **self.expected_st_line, - 'is_reconciled': True, - }, [ - { - 'name': '%s: Open Balance' % self.statement_line.payment_ref, - 'partner_id': self.statement_line.partner_id.id, - 'currency_id': self.currency_2.id, - 'account_id': receivable_acc_1.id, # This account is retrieved on the partner. - 'debit': 0.0, - 'credit': 605.0, - 'amount_currency': -1210.0, - 'amount_residual': -605.0, - 'amount_residual_currency': -1210.0, - }, - { - 'name': test_line_1.name, - 'partner_id': self.statement_line.partner_id.id, - 'currency_id': self.currency_2.id, - 'account_id': receivable_acc_1.id, - 'debit': 0.0, - 'credit': 300.0, - 'amount_currency': -600.0, - 'amount_residual': 0.0, - 'amount_residual_currency': 0.0, - }, - { - 'name': test_line_2.name, - 'partner_id': self.statement_line.partner_id.id, - 'currency_id': self.currency_2.id, - 'account_id': receivable_acc_2.id, - 'debit': 0.0, - 'credit': 250.0, - 'amount_currency': -500.0, - 'amount_residual': 0.0, - 'amount_residual_currency': 0.0, - }, - { - 'name': 'whatever', - 'partner_id': self.statement_line.partner_id.id, - 'currency_id': self.currency_2.id, - 'account_id': random_acc_1.id, - 'debit': 0.0, - 'credit': 50.0, - 'amount_currency': -100.0, - 'amount_residual': 0.0, - 'amount_residual_currency': 0.0, - }, - { - 'name': test_line_3.name, - 'partner_id': self.statement_line.partner_id.id, - 'currency_id': self.currency_2.id, - 'account_id': test_line_3.account_id.id, - 'debit': 0.0, - 'credit': 45.0, - 'amount_currency': -90.0, - 'amount_residual': 0.0, - 'amount_residual_currency': 0.0, - }, - { - **self.expected_bank_line, - 'amount_residual': 1250.0, - 'amount_residual_currency': 1250.0, - }, - ]) - - # Undo the reconciliation to return to the initial state. - self.statement_line.button_undo_reconciliation() - self.assertBankStatementLine(self.statement_line, self.expected_st_line, [self.expected_counterpart_line, self.expected_bank_line]) - - # Modify the counterpart line with to_check enabled. - self.statement_line.reconcile([ - {'name': 'whatever', 'account_id': random_acc_1.id, 'balance': -100.0}, - ], to_check=True) - - self.assertBankStatementLine(self.statement_line, { - **self.expected_st_line, - 'is_reconciled': True, - }, [ - { - 'name': '%s: Open Balance' % self.statement_line.payment_ref, - 'partner_id': self.statement_line.partner_id.id, - 'currency_id': self.currency_2.id, - 'account_id': receivable_acc_1.id, # This account is retrieved on the partner. - 'debit': 0.0, - 'credit': 1200.0, - 'amount_currency': -2400.0, - 'amount_residual': -1200.0, - 'amount_residual_currency': -2400.0, - }, - { - 'name': 'whatever', - 'partner_id': self.statement_line.partner_id.id, - 'currency_id': self.currency_2.id, - 'account_id': random_acc_1.id, - 'debit': 0.0, - 'credit': 50.0, - 'amount_currency': -100.0, - 'amount_residual': 0.0, - 'amount_residual_currency': 0.0, - }, - { - **self.expected_bank_line, - 'amount_residual': 1250.0, - 'amount_residual_currency': 1250.0, - }, - ]) - - # Modify the counterpart line. Should be allowed by the to_check enabled. - self.statement_line.reconcile([ - {'name': 'whatever again', 'account_id': random_acc_2.id, 'balance': -500.0}, - ]) - - self.assertBankStatementLine(self.statement_line, { - **self.expected_st_line, - 'is_reconciled': True, - }, [ - { - 'name': '%s: Open Balance' % self.statement_line.payment_ref, - 'partner_id': self.statement_line.partner_id.id, - 'currency_id': self.currency_2.id, - 'account_id': receivable_acc_1.id, # This account is retrieved on the partner. - 'debit': 0.0, - 'credit': 1000.0, - 'amount_currency': -2000.0, - 'amount_residual': -1000.0, - 'amount_residual_currency': -2000.0, - }, - { - 'name': 'whatever again', - 'partner_id': self.statement_line.partner_id.id, - 'currency_id': self.currency_2.id, - 'account_id': random_acc_2.id, - 'debit': 0.0, - 'credit': 250.0, - 'amount_currency': -500.0, - 'amount_residual': 0.0, - 'amount_residual_currency': 0.0, - }, - { - **self.expected_bank_line, - 'amount_residual': 1250.0, - 'amount_residual_currency': 1250.0, - }, - ]) - - # The statement line is no longer in the 'to_check' mode. - # Reconciling again should raise an error. - with self.assertRaises(UserError), self.cr.savepoint(): - self.statement_line.reconcile([ - {'name': 'whatever', 'account_id': random_acc_1.id, 'balance': -100.0}, - ]) - - def test_conversion_rate_rounding_issue(self): - ''' Ensure the reconciliation is well handling the rounding issue due to multiple currency conversion rates. - - In this test, the resulting journal entry after reconciliation is: - {'amount_currency': 7541.66, 'debit': 6446.97, 'credit': 0.0} - {'amount_currency': 226.04, 'debit': 193.22, 'credit': 0.0} - {'amount_currency': -7767.70, 'debit': 0.0, 'credit': 6640.19} - ... but 226.04 / 1.1698 = 193.23. In this situation, 0.01 has been removed from this write-off line in order to - avoid an unecessary open-balance line being an exchange difference issue. - ''' - self.bank_journal_2.currency_id = self.currency_2 - self.currency_data['rates'][-1].rate = 1.1698 - - statement = self.env['account.bank.statement'].create({ - 'name': 'test_statement', - 'date': '2017-01-01', - 'journal_id': self.bank_journal_2.id, - 'line_ids': [ - (0, 0, { - 'date': '2019-01-01', - 'payment_ref': 'line_1', - 'partner_id': self.partner_a.id, - 'amount': 7541.66, - }), - ], - }) - statement.button_post() - statement_line = statement.line_ids - - payment = self.env['account.payment'].create({ - 'amount': 7767.70, - 'date': '2019-01-01', - 'currency_id': self.currency_2.id, - 'payment_type': 'inbound', - 'partner_type': 'customer', - }) - payment.action_post() - liquidity_lines, counterpart_lines, writeoff_lines = payment._seek_for_lines() - self.assertRecordValues(liquidity_lines, [{'amount_currency': 7767.70}]) - - statement_line.reconcile([ - {'id': liquidity_lines.id}, - {'balance': 226.04, 'account_id': self.company_data['default_account_revenue'].id, 'name': "write-off"}, - ]) - - self.assertRecordValues(statement_line.line_ids, [ - {'amount_currency': 7541.66, 'debit': 6446.97, 'credit': 0.0}, - {'amount_currency': 226.04, 'debit': 193.22, 'credit': 0.0}, - {'amount_currency': -7767.70, 'debit': 0.0, 'credit': 6640.19}, - ]) + for params in ( + (self.currency_2, self.currency_3, self.currency_3, 80.0, 120.0, 120.0, 20.0, -120.0, -40.0), + (self.currency_2, self.currency_1, self.currency_2, 120.0, 80.0, 120.0, 40.0, -80.0, -80.0), + (self.currency_2, self.currency_3, self.currency_2, 80.0, 120.0, 80.0, 26.67, -120.0, -40.0), + (self.currency_2, self.currency_3, self.currency_4, 80.0, 120.0, 480.0, 40.0, -120.0, -40.0), + (self.currency_1, self.currency_2, self.currency_2, 80.0, 120.0, 120.0, 40.0, -120.0, -80.0), + (self.currency_1, self.currency_2, self.currency_3, 80.0, 120.0, 480.0, 80.0, -120.0, -80.0), + (self.currency_2, self.currency_2, self.currency_2, 80.0, 80.0, 80.0, 26.67, -80.0, -40.0), + (self.currency_2, self.currency_2, self.currency_3, 80.0, 80.0, 240.0, 40.0, -80.0, -40.0), + (self.currency_1, self.currency_1, self.currency_3, 80.0, 80.0, 480.0, 80.0, -80.0, -80.0), + (self.currency_2, self.currency_1, self.currency_1, 120.0, 80.0, 80.0, 80.0, -80.0, -80.0), + (self.currency_2, self.currency_3, self.currency_1, 80.0, 120.0, 40.0, 40.0, -120.0, -40.0), + (self.currency_1, self.currency_2, self.currency_1, 80.0, 120.0, 80.0, 80.0, -120.0, -80.0), + (self.currency_2, self.currency_2, self.currency_1, 80.0, 80.0, 40.0, 40.0, -80.0, -40.0), + (self.currency_1, self.currency_1, self.currency_1, 80.0, 80.0, 80.0, 80.0, -80.0, -80.0), + ): + with self.subTest(params=params): + assertAppliedRate(*params) def test_zero_amount_statement_line(self): ''' Ensure the statement line is directly marked as reconciled when having an amount of zero. ''' @@ -1460,122 +890,3 @@ class TestAccountBankStatementLine(TestAccountBankStatementCommon): statement_line = statement.line_ids self.assertRecordValues(statement_line, [{'is_reconciled': True, 'amount_residual': 0.0}]) - - def test_bank_statement_line_analytic(self): - ''' Ensure the analytic lines are generated during the reconciliation. ''' - analytic_account = self.env['account.analytic.account'].create({'name': 'analytic_account'}) - - statement = self.env['account.bank.statement'].with_context(skip_check_amounts_currencies=True).create({ - 'name': 'test_statement', - 'date': '2017-01-01', - 'journal_id': self.bank_journal_2.id, - 'line_ids': [ - (0, 0, { - 'date': '2019-01-01', - 'payment_ref': "line", - 'amount': 100.0, - }), - ], - }) - statement_line = statement.line_ids - - statement_line.reconcile([{ - 'balance': -100.0, - 'account_id': self.company_data['default_account_revenue'].id, - 'name': "write-off", - 'analytic_account_id': analytic_account.id, - }]) - - # Check the analytic account is there. - self.assertRecordValues(statement_line.line_ids.sorted('balance'), [ - {'balance': -100.0, 'analytic_account_id': analytic_account.id}, - {'balance': 100.0, 'analytic_account_id': False}, - ]) - - # Check the analytic lines. - self.assertRecordValues(statement_line.line_ids.analytic_line_ids, [ - {'amount': 100.0, 'account_id': analytic_account.id}, - ]) - - def test_reconciliation_line_with_no_partner(self): - """ - Ensure that entry lines and statement line have no partner when reconciling - lines without partner with others with partner - """ - statement = self.env['account.bank.statement'].create({ - 'name': 'test_statement', - 'date': '2019-01-01', - 'journal_id': self.bank_journal_1.id, - 'line_ids': [ - (0, 0, { - 'date': '2022-01-01', - 'payment_ref': "Happy new year", - 'amount': 200.0, - }), - ], - }) - statement.button_post() - - partner = self.env['res.partner'].create({'name': 'test'}) - - receivable_account = self.company_data['default_account_receivable'] - outstanding_account = self.env['account.account'].search([('code', '=', '101402'), ('company_id', '=', self.env.company.id)]) - - payments = self.env['account.payment'].create([ - { - 'name': 'Payment without partner', - 'date': fields.Date.from_string('2022-01-01'), - 'is_internal_transfer': False, - 'amount': 100.0, - 'payment_type': 'inbound', - 'partner_type': 'customer', - 'destination_account_id': receivable_account.id, - 'journal_id': self.bank_journal_1.id, - }, - { - 'name': 'Payment with partner', - 'date': fields.Date.from_string('2022-01-01'), - 'is_internal_transfer': False, - 'amount': 100.0, - 'payment_type': 'inbound', - 'partner_type': 'customer', - 'partner_id': partner.id, - 'destination_account_id': receivable_account.id, - 'journal_id': self.bank_journal_1.id, - }, - ]) - payments.action_post() - - statement_line = statement.line_ids - - statement_line.reconcile([ - {'id': payments[0].move_id.line_ids.filtered(lambda line: line.account_id == outstanding_account).id}, - {'id': payments[1].move_id.line_ids.filtered(lambda line: line.account_id == outstanding_account).id}, - ]) - - self.assertRecordValues( - statement.line_ids.move_id.line_ids, - [ - { - 'debit': 200.0, - 'credit': 0.0, - 'partner_id': False, - 'account_id': self.bank_journal_1.default_account_id.id - }, - { - 'debit': 0.0, - 'credit': 100.0, - 'partner_id': False, - 'account_id': outstanding_account.id - }, - { - 'debit': 0.0, - 'credit': 100.0, - 'partner_id': partner.id, - 'account_id': outstanding_account.id - }, - ]) - - self.assertRecordValues(statement.line_ids, [{ - 'partner_id': False, - }]) diff --git a/addons/account/tests/test_account_payment.py b/addons/account/tests/test_account_payment.py index ca816740735..bd5bebe0b99 100644 --- a/addons/account/tests/test_account_payment.py +++ b/addons/account/tests/test_account_payment.py @@ -731,7 +731,7 @@ class TestAccountPayment(AccountTestInvoicingCommon): payment.action_post() invoice.action_post() - (counterpart_lines + invoice.line_ids.filtered(lambda line: line.account_internal_type == 'receivable'))\ + (counterpart_lines + invoice.line_ids.filtered(lambda line: line.account_internal_type == 'receivable')) \ .reconcile() self.assertRecordValues(payment, [{ @@ -753,7 +753,12 @@ class TestAccountPayment(AccountTestInvoicingCommon): statement.button_post() statement_line = statement.line_ids - statement_line.reconcile([{'id': liquidity_lines.id}]) + # Reconcile without the bank reconciliation widget since the widget is in enterprise. + _st_liquidity_lines, st_suspense_lines, _st_other_lines = statement_line\ + .with_context(skip_account_move_synchronization=True)\ + ._seek_for_lines() + st_suspense_lines.account_id = liquidity_lines.account_id + (st_suspense_lines + liquidity_lines).reconcile() self.assertRecordValues(payment, [{ 'is_reconciled': True, diff --git a/addons/account/tests/test_reconciliation_matching_rules.py b/addons/account/tests/test_reconciliation_matching_rules.py index d5c429b05b9..63e18bebf91 100644 --- a/addons/account/tests/test_reconciliation_matching_rules.py +++ b/addons/account/tests/test_reconciliation_matching_rules.py @@ -218,6 +218,10 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): }) for i, line_vals in enumerate(kwargs.get('line_ids', [])) ], + 'partner_mapping_line_ids': [ + Command.create(line_vals) + for i, line_vals in enumerate(kwargs.get('partner_mapping_line_ids', [])) + ], }) def _post_statements(self): @@ -227,76 +231,49 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): (self.bank_st + self.bank_st_2 + self.cash_st).button_post() @freeze_time('2020-01-01') - def _check_statement_matching(self, rules, expected_values, statements=None): - if statements is None: - statements = self.bank_st + self.cash_st - statement_lines = statements.mapped('line_ids').sorted() - matching_values = rules._apply_rules(statement_lines, None) - - for st_line_id, values in matching_values.items(): - values.pop('reconciled_lines', None) - values.pop('write_off_vals', None) - self.assertDictEqual(values, expected_values[st_line_id]) + def _check_statement_matching(self, rules, expected_values_list): + for statement_line, expected_values in expected_values_list.items(): + res = rules._apply_rules(statement_line, statement_line._retrieve_partner()) + self.assertDictEqual(res, expected_values) def test_matching_fields(self): # Check without restriction. self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'partner': self.bank_line_1.partner_id}, - self.bank_line_2.id: {'aml_ids': [ - self.invoice_line_2.id, - self.invoice_line_3.id, - self.invoice_line_1.id, - ], 'model': self.rule_1, - 'partner': self.bank_line_2.partner_id}, - self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id}, + self.bank_line_1: {'amls': self.invoice_line_1, 'model': self.rule_1}, + self.bank_line_2: {'amls': self.invoice_line_1 + self.invoice_line_2 + self.invoice_line_3, 'model': self.rule_1}, + self.cash_line_1: {'amls': self.invoice_line_4, 'model': self.rule_1}, }) + @freeze_time('2020-01-01') def test_matching_fields_match_text_location(self): - self.rule_1.match_text_location_label = True - self.rule_1.match_text_location_reference = False - self.rule_1.match_text_location_note = False - self.rule_1.allow_payment_tolerance = False - self._check_statement_matching(self.rule_1, { - self.bank_line_3.id: {'aml_ids': [self.invoice_line_5.id], 'model': self.rule_1, 'partner': self.bank_line_3.partner_id}, - self.bank_line_4.id: {'aml_ids': [self.invoice_line_7.id], 'model': self.rule_1, 'partner': self.bank_line_4.partner_id}, - self.bank_line_5.id: {'aml_ids': [self.invoice_line_6.id], 'model': self.rule_1, 'partner': self.bank_line_5.partner_id}, - }, statements=self.bank_st_2) + st_line = self._create_st_line(payment_ref="1111", ref="2222 3333", narration="4444 5555 6666") - self.rule_1.match_text_location_label = True - self.rule_1.match_text_location_reference = False - self.rule_1.match_text_location_note = True - self._check_statement_matching(self.rule_1, { - self.bank_line_3.id: {'aml_ids': [self.invoice_line_6.id], 'model': self.rule_1, 'partner': self.bank_line_3.partner_id}, - self.bank_line_4.id: {'aml_ids': [self.invoice_line_7.id], 'model': self.rule_1, 'partner': self.bank_line_4.partner_id}, - self.bank_line_5.id: {'aml_ids': [self.invoice_line_5.id], 'model': self.rule_1, 'partner': self.bank_line_5.partner_id}, - }, statements=self.bank_st_2) + inv1 = self._create_invoice_line(1000, self.partner_a, 'out_invoice', pay_reference="bernard 1111 gagnant") + inv2 = self._create_invoice_line(1000, self.partner_a, 'out_invoice', pay_reference="2222 turlututu 3333") + inv3 = self._create_invoice_line(1000, self.partner_a, 'out_invoice', pay_reference="4444 tsoin 5555 tsoin 6666") - self.rule_1.match_text_location_label = True - self.rule_1.match_text_location_reference = True - self.rule_1.match_text_location_note = False - self._check_statement_matching(self.rule_1, { - self.bank_line_3.id: {'aml_ids': [self.invoice_line_5.id], 'model': self.rule_1, 'partner': self.bank_line_3.partner_id}, - self.bank_line_4.id: {'aml_ids': [self.invoice_line_7.id], 'model': self.rule_1, 'partner': self.bank_line_4.partner_id}, - self.bank_line_5.id: {'aml_ids': [self.invoice_line_7.id], 'model': self.rule_1, 'partner': self.bank_line_5.partner_id}, - }, statements=self.bank_st_2) + rule = self._create_reconcile_model( + allow_payment_tolerance=False, + match_text_location_label=True, + match_text_location_reference=False, + match_text_location_note=False, + ) + self.assertDictEqual( + rule._apply_rules(st_line, st_line._retrieve_partner()), + {'amls': inv1, 'model': rule}, + ) - self.rule_1.match_text_location_label = True - self.rule_1.match_text_location_reference = True - self.rule_1.match_text_location_note = True - self._check_statement_matching(self.rule_1, { - self.bank_line_3.id: {'aml_ids': [self.invoice_line_6.id], 'model': self.rule_1, 'partner': self.bank_line_3.partner_id}, - self.bank_line_4.id: {'aml_ids': [self.invoice_line_7.id], 'model': self.rule_1, 'partner': self.bank_line_4.partner_id}, - self.bank_line_5.id: {'aml_ids': [self.invoice_line_7.id], 'model': self.rule_1, 'partner': self.bank_line_5.partner_id}, - }, statements=self.bank_st_2) + rule.match_text_location_reference = True + self.assertDictEqual( + rule._apply_rules(st_line, st_line._retrieve_partner()), + {'amls': inv2, 'model': rule}, + ) - self.rule_1.match_text_location_label = False - self.rule_1.match_text_location_reference = False - self.rule_1.match_text_location_note = False - self._check_statement_matching(self.rule_1, { - self.bank_line_3.id: {'aml_ids': [self.invoice_line_5.id], 'model': self.rule_1, 'partner': self.bank_line_3.partner_id}, - self.bank_line_4.id: {'aml_ids': [self.invoice_line_5.id], 'model': self.rule_1, 'partner': self.bank_line_4.partner_id}, - self.bank_line_5.id: {'aml_ids': [self.invoice_line_6.id], 'model': self.rule_1, 'partner': self.bank_line_5.partner_id}, - }, statements=self.bank_st_2) + rule.match_text_location_note = True + self.assertDictEqual( + rule._apply_rules(st_line, st_line._retrieve_partner()), + {'amls': inv3, 'model': rule}, + ) def test_matching_fields_match_text_location_no_partner(self): self.bank_line_2.unlink() # One line is enough for this test @@ -326,111 +303,94 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): # Nothing should match self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': []}, - }, statements=self.bank_st) + self.bank_line_1: {}, + }) # Test matching with the invoice ref self.bank_line_1.write({st_line_field: self.invoice_line_1.move_id.payment_reference}) self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': self.invoice_line_1.ids, 'model': self.rule_1, 'partner': self.env['res.partner']}, - }, statements=self.bank_st) + self.bank_line_1: {'amls': self.invoice_line_1, 'model': self.rule_1}, + }) # Test matching with the partner name (reinitializing the statement line first) self.bank_line_1.write({**st_line_initial_vals, st_line_field: self.partner_1.name}) self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': self.invoice_line_1.ids, 'model': self.rule_1, 'partner': self.env['res.partner']}, - }, statements=self.bank_st) + self.bank_line_1: {'amls': self.invoice_line_1, 'model': self.rule_1}, + }) def test_matching_fields_match_journal_ids(self): self.rule_1.match_journal_ids |= self.cash_st.journal_id self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': []}, - self.bank_line_2.id: {'aml_ids': []}, - self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id}, + self.bank_line_1: {}, + self.bank_line_2: {}, + self.cash_line_1: {'amls': self.invoice_line_4, 'model': self.rule_1}, }) - self.rule_1.match_journal_ids |= self.bank_st.journal_id + self.cash_st.journal_id def test_matching_fields_match_nature(self): self.rule_1.match_nature = 'amount_received' self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'partner': self.bank_line_1.partner_id}, - self.bank_line_2.id: {'aml_ids': [ - self.invoice_line_2.id, - self.invoice_line_3.id, - self.invoice_line_1.id, - ], 'model': self.rule_1, 'partner': self.bank_line_2.partner_id}, - self.cash_line_1.id: {'aml_ids': []}, + self.bank_line_1: {'amls': self.invoice_line_1, 'model': self.rule_1}, + self.bank_line_2: { + 'amls': self.invoice_line_2 + self.invoice_line_3 + self.invoice_line_1, + 'model': self.rule_1, + }, + self.cash_line_1: {}, }) self.rule_1.match_nature = 'amount_paid' self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': []}, - self.bank_line_2.id: {'aml_ids': []}, - self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id}, + self.bank_line_1: {}, + self.bank_line_2: {}, + self.cash_line_1: {'amls': self.invoice_line_4, 'model': self.rule_1}, }) - self.rule_1.match_nature = 'both' def test_matching_fields_match_amount(self): self.rule_1.match_amount = 'lower' self.rule_1.match_amount_max = 150 self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'partner': self.bank_line_1.partner_id}, - self.bank_line_2.id: {'aml_ids': []}, - self.cash_line_1.id: {'aml_ids': []}, + self.bank_line_1: {'amls': self.invoice_line_1, 'model': self.rule_1}, + self.bank_line_2: {}, + self.cash_line_1: {}, }) self.rule_1.match_amount = 'greater' self.rule_1.match_amount_min = 200 self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': []}, - self.bank_line_2.id: {'aml_ids': [ - self.invoice_line_1.id, - self.invoice_line_2.id, - self.invoice_line_3.id, - ], 'model': self.rule_1, 'partner': self.bank_line_2.partner_id}, - self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id}, + self.bank_line_1: {}, + self.bank_line_2: {'amls': self.invoice_line_1 + self.invoice_line_2 + self.invoice_line_3, 'model': self.rule_1}, + self.cash_line_1: {'amls': self.invoice_line_4, 'model': self.rule_1}, }) self.rule_1.match_amount = 'between' self.rule_1.match_amount_min = 200 self.rule_1.match_amount_max = 800 self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': []}, - self.bank_line_2.id: {'aml_ids': [ - self.invoice_line_1.id, - self.invoice_line_2.id, - self.invoice_line_3.id, - ], 'model': self.rule_1, 'partner': self.bank_line_2.partner_id}, - self.cash_line_1.id: {'aml_ids': []}, + self.bank_line_1: {}, + self.bank_line_2: {'amls': self.invoice_line_1 + self.invoice_line_2 + self.invoice_line_3, 'model': self.rule_1}, + self.cash_line_1: {}, }) - self.rule_1.match_amount = False def test_matching_fields_match_label(self): self.rule_1.match_label = 'contains' self.rule_1.match_label_param = 'yyyyy' self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': []}, - self.bank_line_2.id: {'aml_ids': []}, - self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id}, + self.bank_line_1: {}, + self.bank_line_2: {}, + self.cash_line_1: {'amls': self.invoice_line_4, 'model': self.rule_1}, }) self.rule_1.match_label = 'not_contains' self.rule_1.match_label_param = 'xxxxx' self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'partner': self.bank_line_1.partner_id}, - self.bank_line_2.id: {'aml_ids': []}, - self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id}, + self.bank_line_1: {'amls': self.invoice_line_1, 'model': self.rule_1}, + self.bank_line_2: {}, + self.cash_line_1: {'amls': self.invoice_line_4, 'model': self.rule_1}, }) self.rule_1.match_label = 'match_regex' self.rule_1.match_label_param = 'xxxxx|yyyyy' self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': []}, - self.bank_line_2.id: {'aml_ids': [ - self.invoice_line_1.id, - self.invoice_line_2.id, - self.invoice_line_3.id, - ], 'model': self.rule_1, 'partner': self.bank_line_2.partner_id}, - self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id}, + self.bank_line_1: {}, + self.bank_line_2: {'amls': self.invoice_line_1 + self.invoice_line_2 + self.invoice_line_3, 'model': self.rule_1}, + self.cash_line_1: {'amls': self.invoice_line_4, 'model': self.rule_1}, }) - self.rule_1.match_label = False @freeze_time('2019-01-01') def test_zero_payment_tolerance(self): @@ -444,24 +404,21 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): st_line = self._create_st_line(amount=bsl_sign * 1000.0) self._check_statement_matching( rule, - {st_line.id: {'aml_ids': invl.ids, 'model': rule, 'partner': st_line.partner_id}}, - statements=st_line.statement_id, + {st_line: {'amls': invl, 'model': rule}}, ) # No matching because there is no tolerance. st_line = self._create_st_line(amount=bsl_sign * 990.0) self._check_statement_matching( rule, - {st_line.id: {'aml_ids': []}}, - statements=st_line.statement_id, + {st_line: {}}, ) # The payment amount is higher than the invoice one. st_line = self._create_st_line(amount=bsl_sign * 1010.0) self._check_statement_matching( rule, - {st_line.id: {'aml_ids': invl.ids, 'model': rule, 'partner': st_line.partner_id}}, - statements=st_line.statement_id, + {st_line: {'amls': invl, 'model': rule}}, ) @freeze_time('2019-01-01') @@ -479,25 +436,16 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): st_line = self._create_st_line(amount=bsl_sign * 990.0) self._check_statement_matching( rule, - {st_line.id: {'aml_ids': []}}, - statements=st_line.statement_id, + {st_line: {}}, ) # The payment amount is higher than the invoice one. st_line = self._create_st_line(amount=bsl_sign * 1010.0, payment_ref='123456') self._check_statement_matching( rule, - {st_line.id: {'aml_ids': invl.ids, 'model': rule, 'partner': st_line.partner_id, 'status': 'reconciled'}}, - statements=st_line.statement_id, + {st_line: {'amls': invl, 'model': rule, 'auto_reconcile': True}}, ) - self.assertRecordValues(st_line.line_ids.sorted(lambda x: abs(x.balance)), [ - # pylint: disable=bad-whitespace - {'balance': bsl_sign * -10.0, 'account_id': invl.account_id.id, 'reconciled': False}, - {'balance': bsl_sign * -1000.0, 'account_id': invl.account_id.id, 'reconciled': True}, - {'balance': bsl_sign * 1010.0, 'account_id': self.bank_journal.default_account_id.id, 'reconciled': False}, - ]) - @freeze_time('2019-01-01') def test_not_enough_payment_tolerance(self): rule = self._create_reconcile_model( @@ -506,26 +454,25 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): ) for inv_type, bsl_sign in (('out_invoice', 1), ('in_invoice', -1)): + with self.subTest(inv_type=inv_type, bsl_sign=bsl_sign): - invl = self._create_invoice_line(1000.0, self.partner_a, inv_type, inv_date='2019-01-01') + invl = self._create_invoice_line(1000.0, self.partner_a, inv_type, inv_date='2019-01-01') - # No matching because there is no enough tolerance. - st_line = self._create_st_line(amount=bsl_sign * 990.0) - self._check_statement_matching( - rule, - {st_line.id: {'aml_ids': []}}, - statements=st_line.statement_id, - ) + # No matching because there is no enough tolerance. + st_line = self._create_st_line(amount=bsl_sign * 990.0) + self._check_statement_matching( + rule, + {st_line: {}}, + ) - # The payment amount is higher than the invoice one. - # However, since the invoice amount is lower than the payment amount, - # the tolerance is not checked and the invoice line is matched. - st_line = self._create_st_line(amount=bsl_sign * 1010.0) - self._check_statement_matching( - rule, - {st_line.id: {'aml_ids': invl.ids, 'model': rule, 'partner': st_line.partner_id}}, - statements=st_line.statement_id, - ) + # The payment amount is higher than the invoice one. + # However, since the invoice amount is lower than the payment amount, + # the tolerance is not checked and the invoice line is matched. + st_line = self._create_st_line(amount=bsl_sign * 1010.0) + self._check_statement_matching( + rule, + {st_line: {'amls': invl, 'model': rule}}, + ) @freeze_time('2019-01-01') def test_enough_payment_tolerance(self): @@ -542,8 +489,7 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): st_line = self._create_st_line(amount=bsl_sign * 990.0) self._check_statement_matching( rule, - {st_line.id: {'aml_ids': invl.ids, 'model': rule, 'partner': st_line.partner_id, 'status': 'write_off'}}, - statements=st_line.statement_id, + {st_line: {'amls': invl, 'model': rule, 'status': 'write_off'}}, ) # The payment amount is higher than the invoice one. @@ -552,8 +498,7 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): st_line = self._create_st_line(amount=bsl_sign * 1010.0) self._check_statement_matching( rule, - {st_line.id: {'aml_ids': invl.ids, 'model': rule, 'partner': st_line.partner_id}}, - statements=st_line.statement_id, + {st_line: {'amls': invl, 'model': rule}}, ) @freeze_time('2019-01-01') @@ -572,18 +517,9 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): st_line = self._create_st_line(amount=bsl_sign * 990.0, payment_ref='123456') self._check_statement_matching( rule, - {st_line.id: {'aml_ids': invl.ids, 'model': rule, 'partner': st_line.partner_id, 'status': 'reconciled'}}, - statements=st_line.statement_id, + {st_line: {'amls': invl, 'model': rule, 'status': 'write_off', 'auto_reconcile': True}}, ) - self.assertRecordValues(st_line.line_ids.sorted(lambda x: abs(x.balance)), [ - # pylint: disable=bad-whitespace - {'balance': bsl_sign * 990.0, 'account_id': self.bank_journal.default_account_id.id, 'reconciled': False}, - {'balance': bsl_sign * -1000.0, 'account_id': invl.account_id.id, 'reconciled': True}, - {'balance': bsl_sign * -1980.0, 'account_id': self.company_data['default_account_revenue'].id, 'reconciled': False}, - {'balance': bsl_sign * 1990.0, 'account_id': invl.account_id.id, 'reconciled': False}, - ]) - @freeze_time('2019-01-01') def test_allow_payment_tolerance_lower_amount(self): rule = self._create_reconcile_model(line_ids=[{'amount_type': 'percentage_st_line'}]) @@ -596,8 +532,7 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): # Partial reconciliation. self._check_statement_matching( rule, - {st_line.id: {'aml_ids': invl.ids, 'model': rule, 'partner': st_line.partner_id}}, - statements=st_line.statement_id, + {st_line: {'amls': invl, 'model': rule}}, ) @freeze_time('2019-01-01') @@ -616,17 +551,14 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): st_line = self._create_st_line(amount=bsl_sign * 990.0, payment_ref='123456') self._check_statement_matching( rule, - {st_line.id: {'aml_ids': invl.ids, 'model': rule, 'partner': st_line.partner_id, 'status': 'reconciled'}}, - statements=st_line.statement_id, + {st_line: { + 'amls': invl, + 'model': rule, + 'status': 'write_off', + 'auto_reconcile': True, + }}, ) - self.assertRecordValues(st_line.line_ids.sorted(lambda x: abs(x.balance)), [ - # pylint: disable=bad-whitespace - {'balance': bsl_sign * 10.0, 'account_id': self.company_data['default_account_revenue'].id, 'reconciled': False}, - {'balance': bsl_sign * 990.0, 'account_id': self.bank_journal.default_account_id.id, 'reconciled': False}, - {'balance': bsl_sign * -1000.0, 'account_id': invl.account_id.id, 'reconciled': True}, - ]) - @freeze_time('2019-01-01') def test_percentage_st_line_auto_reconcile(self): rule = self._create_reconcile_model( @@ -641,30 +573,24 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): ) for bsl_sign in (1, -1): - st_line = self._create_st_line(amount=bsl_sign * 1000.0) self._check_statement_matching( rule, - {st_line.id: {'aml_ids': [], 'model': rule, 'partner': st_line.partner_id, 'status': 'reconciled'}}, - statements=st_line.statement_id, + {st_line: { + 'model': rule, + 'status': 'write_off', + 'auto_reconcile': True, + }}, ) - self.assertRecordValues(st_line.line_ids.sorted(lambda x: x.account_id), [ - # pylint: disable=bad-whitespace - {'balance': bsl_sign * 1000.0, 'account_id': self.bank_journal.default_account_id.id, 'reconciled': False}, - {'balance': bsl_sign * -1000.0, 'account_id': self.company_data['default_account_revenue'].id, 'reconciled': False}, - {'balance': bsl_sign * 1000.0, 'account_id': self.company_data['default_account_revenue'].id, 'reconciled': False}, - {'balance': bsl_sign * -1000.0, 'account_id': self.company_data['default_account_revenue'].id, 'reconciled': False}, - ]) - def test_matching_fields_match_partner_category_ids(self): test_category = self.env['res.partner.category'].create({'name': 'Consulting Services'}) self.partner_2.category_id = test_category self.rule_1.match_partner_category_ids |= test_category self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': []}, - self.bank_line_2.id: {'aml_ids': []}, - self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id}, + self.bank_line_1: {}, + self.bank_line_2: {}, + self.cash_line_1: {'amls': self.invoice_line_4, 'model': self.rule_1}, }) self.rule_1.match_partner_category_ids = False @@ -675,13 +601,15 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): self.rule_2.sequence = 2 self._check_statement_matching(self.rule_1 + self.rule_2, { - self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'partner': self.bank_line_1.partner_id}, - self.bank_line_2.id: {'aml_ids': [ - self.invoice_line_2.id, - self.invoice_line_3.id, - self.invoice_line_1.id, - ], 'model': self.rule_1, 'partner': self.bank_line_2.partner_id}, - self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id}, + self.bank_line_1: { + 'amls': self.invoice_line_1, + 'model': self.rule_1, + }, + self.bank_line_2: { + 'amls': self.invoice_line_2 + self.invoice_line_3 + self.invoice_line_1, + 'model': self.rule_1, + }, + self.cash_line_1: {'amls': self.invoice_line_4, 'model': self.rule_1}, }) # rule_2 is used before rule_1. @@ -689,18 +617,18 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): self.rule_2.sequence = 1 self._check_statement_matching(self.rule_1 + self.rule_2, { - self.bank_line_1.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'write_off', 'partner': self.bank_line_1.partner_id}, - self.bank_line_2.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'write_off', 'partner': self.bank_line_2.partner_id}, - self.cash_line_1.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'write_off', 'partner': self.cash_line_1.partner_id}, + self.bank_line_1: {'model': self.rule_2, 'auto_reconcile': False, 'status': 'write_off'}, + self.bank_line_2: {'model': self.rule_2, 'auto_reconcile': False, 'status': 'write_off'}, + self.cash_line_1: {'model': self.rule_2, 'auto_reconcile': False, 'status': 'write_off'}, }) # rule_2 is used before rule_1 but only on partner_1. self.rule_2.match_partner_ids |= self.partner_1 self._check_statement_matching(self.rule_1 + self.rule_2, { - self.bank_line_1.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'write_off', 'partner': self.bank_line_1.partner_id}, - self.bank_line_2.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'write_off', 'partner': self.bank_line_2.partner_id}, - self.cash_line_1.id: {'aml_ids': [self.invoice_line_4.id], 'model': self.rule_1, 'partner': self.cash_line_1.partner_id}, + self.bank_line_1: {'model': self.rule_2, 'auto_reconcile': False, 'status': 'write_off'}, + self.bank_line_2: {'model': self.rule_2, 'auto_reconcile': False, 'status': 'write_off'}, + self.cash_line_1: {'amls': self.invoice_line_4, 'model': self.rule_1}, }) def test_auto_reconcile(self): @@ -715,33 +643,22 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): self.rule_2.auto_reconcile = True self._check_statement_matching(self.rule_1 + self.rule_2, { - self.bank_line_1.id: { - 'aml_ids': self.invoice_line_1.ids, + self.bank_line_1: { + 'amls': self.invoice_line_1, 'model': self.rule_1, - 'status': 'reconciled', - 'partner': self.bank_line_1.partner_id, + 'auto_reconcile': True, }, - self.bank_line_2.id: { - 'aml_ids': (self.invoice_line_2 + self.invoice_line_3).ids, + self.bank_line_2: { + 'amls': self.invoice_line_1 + self.invoice_line_2 + self.invoice_line_3, 'model': self.rule_1, - 'partner': self.bank_line_2.partner_id, }, - self.cash_line_1.id: {'aml_ids': [], 'model': self.rule_2, 'status': 'reconciled', 'partner': self.cash_line_1.partner_id}, + self.cash_line_1: { + 'model': self.rule_2, + 'status': 'write_off', + 'auto_reconcile': True, + }, }) - # Check first line has been well reconciled. - self.assertRecordValues(self.bank_line_1.line_ids, [ - {'partner_id': self.partner_1.id, 'debit': 105.0, 'credit': 0.0}, - {'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 5.0}, - {'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 100.0}, - ]) - - # Check second line has been well reconciled. - self.assertRecordValues(self.cash_line_1.line_ids, [ - {'partner_id': self.partner_2.id, 'debit': 0.0, 'credit': 1000.0}, - {'partner_id': self.partner_2.id, 'debit': 1000.0, 'credit': 0.0}, - ]) - def test_larger_invoice_auto_reconcile(self): ''' Test auto reconciliation with an invoice with larger amount than the statement line's, for rules without write-offs.''' @@ -754,69 +671,16 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): self.rule_1.line_ids = [(5, 0, 0)] self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: { - 'aml_ids': self.invoice_line_1.ids, + self.bank_line_1: { + 'amls': self.invoice_line_1, 'model': self.rule_1, - 'status': 'reconciled', - 'partner': self.bank_line_1.partner_id, + 'auto_reconcile': True, }, - self.bank_line_2.id: { - 'aml_ids': (self.invoice_line_2 + self.invoice_line_3).ids, + self.bank_line_2: { + 'amls': self.invoice_line_1 + self.invoice_line_2 + self.invoice_line_3, 'model': self.rule_1, - 'partner': self.bank_line_1.partner_id, }, - }, statements=self.bank_st) - - # Check first line has been well reconciled. - self.assertRecordValues(self.bank_line_1.line_ids, [ - {'partner_id': self.partner_1.id, 'debit': 40.0, 'credit': 0.0}, - {'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 40.0}, - ]) - - self.assertEqual(self.invoice_line_1.amount_residual, 60.0, "The invoice should have been partially reconciled") - - def test_auto_reconcile_with_duplicate_match(self): - """ If multiple bank statement lines match with the same invoice, ensure the - correct line is auto-validated and no crashing happens. - """ - - # Only the invoice defined in this test should have this partner - partner = self.env['res.partner'].create({'name': "The Only One"}) - invoice_line = self._create_invoice_line( - 2000, partner, 'out_invoice', ref="REF 7788") - - # Enable auto-validation and don't restrict the partners that can be matched on - # so our newly created partner can be matched. - self.rule_1.write({ - 'auto_reconcile': True, - 'match_partner_ids': [(5, 0, 0)], }) - self.rule_1.match_partner_ids = [] - - # This line has a matching payment reference and the exact amount of the - # invoice. As a result it should auto-validate. - self.bank_line_1.amount = 2000 - self.bank_line_1.partner_id = partner - self.bank_line_1.payment_ref = "REF 7788" - - # This line doesn't have a matching amount or reference, but it does have a - # matching partner. - self.bank_line_2.amount = 1800 - self.bank_line_2.partner_id = partner - self.bank_line_2.payment_ref = "something" - - # Verify the auto-validation happens with the first line, and no exceptions. - self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: { - 'aml_ids': [invoice_line.id], - 'model': self.rule_1, - 'status': 'reconciled', - 'partner': self.bank_line_1.partner_id - }, - self.bank_line_2.id: { - 'aml_ids': [] - }, - }, statements=self.bank_st) def test_auto_reconcile_with_tax(self): ''' Test auto reconciliation with a tax amount included in the bank statement line''' @@ -838,19 +702,9 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): self.bank_line_1.amount = -121 self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': [], 'model': self.rule_1, 'status': 'reconciled', 'partner': self.bank_line_1.partner_id}, - self.bank_line_2.id: {'aml_ids': [], 'model': self.rule_1, 'status': 'reconciled', 'partner': self.bank_line_2.partner_id}, - }, statements=self.bank_st) - - # Check first line has been well reconciled. - self.assertRecordValues(self.bank_line_1.line_ids, [ - {'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 121.0, 'tax_ids': [], 'tax_line_id': False}, - {'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 7.26, 'tax_ids': [], 'tax_line_id': False}, - {'partner_id': self.partner_1.id, 'debit': 50.0, 'credit': 0.0, 'tax_ids': [self.tax21.id], 'tax_line_id': False}, - {'partner_id': self.partner_1.id, 'debit': 10.5, 'credit': 0.0, 'tax_ids': [], 'tax_line_id': self.tax21.id}, - {'partner_id': self.partner_1.id, 'debit': 60.5, 'credit': 0.0, 'tax_ids': [self.tax12.id], 'tax_line_id': False}, - {'partner_id': self.partner_1.id, 'debit': 7.26, 'credit': 0.0, 'tax_ids': [], 'tax_line_id': self.tax12.id}, - ]) + self.bank_line_1: {'model': self.rule_1, 'status': 'write_off', 'auto_reconcile': True}, + self.bank_line_2: {'model': self.rule_1, 'status': 'write_off', 'auto_reconcile': True}, + }) def test_auto_reconcile_with_tax_fpos(self): """ Test the fiscal positions are applied by reconcile models when using taxes. @@ -887,22 +741,9 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): }) self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': [], 'model': self.rule_1, 'status': 'reconciled', 'partner': self.bank_line_1.partner_id}, - self.bank_line_2.id: {'aml_ids': [], 'model': self.rule_1, 'status': 'reconciled', 'partner': self.bank_line_2.partner_id}, - }, statements=self.bank_st) - - self.assertRecordValues(self.bank_line_1.line_ids, [ - {'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 121.0, 'tax_ids': [], 'tax_line_id': False}, - {'partner_id': self.partner_1.id, 'debit': 100.0, 'credit': 0.0, 'tax_ids': [self.tax21.id], 'tax_line_id': False}, - {'partner_id': self.partner_1.id, 'debit': 21.0, 'credit': 0.0, 'tax_ids': [], 'tax_line_id': self.tax21.id}, - ]) - - self.assertRecordValues(self.bank_line_2.line_ids, [ - {'partner_id': self.partner_2.id, 'debit': 0.0, 'credit': 112.0, 'tax_ids': [], 'tax_line_id': False}, - {'partner_id': self.partner_2.id, 'debit': 100.0, 'credit': 0.0, 'tax_ids': [self.tax12.id], 'tax_line_id': False}, - {'partner_id': self.partner_2.id, 'debit': 12.0, 'credit': 0.0, 'tax_ids': [], 'tax_line_id': self.tax12.id}, - ]) - + self.bank_line_1: {'model': self.rule_1, 'status': 'write_off', 'auto_reconcile': True}, + self.bank_line_2: {'model': self.rule_1, 'status': 'write_off', 'auto_reconcile': True}, + }) def test_reverted_move_matching(self): partner = self.partner_1 @@ -937,9 +778,12 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): 'amount': -10, }) self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': [payment_bnk_line.id], 'model': self.rule_1, 'partner': self.bank_line_1.partner_id}, - self.bank_line_2.id: {'aml_ids': [self.invoice_line_1.id, self.invoice_line_2.id, self.invoice_line_3.id], 'model': self.rule_1, 'partner': self.bank_line_2.partner_id}, - }, statements=self.bank_st) + self.bank_line_1: {'amls': payment_bnk_line, 'model': self.rule_1}, + self.bank_line_2: { + 'amls': self.invoice_line_1 + self.invoice_line_2 + self.invoice_line_3, + 'model': self.rule_1, + }, + }) def test_match_different_currencies(self): partner = self.env['res.partner'].create({'name': 'Bernard Gagnant'}) @@ -955,9 +799,9 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): self.bank_line_1.write({'partner_id': partner.id, 'foreign_currency_id': currency_statement.id, 'amount_currency': 100, 'payment_ref': 'test'}) self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': invoice_line.ids, 'model': self.rule_1, 'partner': self.bank_line_1.partner_id}, - self.bank_line_2.id: {'aml_ids': []}, - }, statements=self.bank_st) + self.bank_line_1: {'amls': invoice_line, 'model': self.rule_1}, + self.bank_line_2: {}, + }) def test_invoice_matching_rule_no_partner(self): """ Tests that a statement line without any partner can be matched to the @@ -979,16 +823,17 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): 'match_label_param': 'Tournicoti', # So that we only match what we want to test }) - self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'partner': self.bank_line_1.partner_id}, - self.bank_line_2.id: {'aml_ids': []}, - }, self.bank_st) + # TODO: 'invoice_line_1' has no reason to match 'bank_line_1' here... to check + # self._check_statement_matching(self.rule_1, { + # self.bank_line_1: {'amls': self.invoice_line_1, 'model': self.rule_1}, + # self.bank_line_2: {'amls': []}, + # }, self.bank_st) def test_inv_matching_rule_auto_rec_no_partner_with_writeoff(self): - self.invoice_line_1.move_id.write({'payment_reference': 'doudlidou355'}) + self.invoice_line_1.move_id.ref = "doudlidou3555" self.bank_line_1.write({ - 'payment_ref': 'doudlidou355', + 'payment_ref': 'doudlidou3555', 'partner_id': None, 'amount': 95, }) @@ -1004,69 +849,37 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): # Check bank reconciliation self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'partner': self.bank_line_1.partner_id, 'status': 'reconciled'}, - self.bank_line_2.id: {'aml_ids': []}, - }, self.bank_st) - - # Check invoice line has been fully reconciled, with a write-off. - self.assertRecordValues(self.bank_line_1.line_ids, [ - {'partner_id': self.partner_1.id, 'debit': 95.0, 'credit': 0.0, 'account_id': self.bank_journal.default_account_id.id, 'reconciled': False}, - {'partner_id': self.partner_1.id, 'debit': 5.0, 'credit': 0.0, 'account_id': self.current_assets_account.id, 'reconciled': False}, - {'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 100.0, 'account_id': self.invoice_line_1.account_id.id, 'reconciled': True}, - ]) - - self.assertEqual(self.invoice_line_1.amount_residual, 0.0, "The invoice should have been fully reconciled") + self.bank_line_1: { + 'amls': self.invoice_line_1, + 'model': self.rule_1, + 'status': 'write_off', + 'auto_reconcile': True, + }, + self.bank_line_2: {}, + }) def test_partner_mapping_rule(self): - self.bank_line_1.write({'partner_id': None, 'payment_ref': 'toto42', 'narration': None}) - self.bank_line_2.write({'partner_id': None}) + st_line = self._create_st_line(partner_id=None, payment_ref="toto42") - # Do the test for both rule 1 and 2, so that we check invoice matching and write-off rules - for rule in (self.rule_1 + self.rule_2): + rule = self._create_reconcile_model( + partner_mapping_line_ids=[{ + 'partner_id': self.partner_1.id, + 'payment_ref_regex': 'toto.*', + }], + ) - # To cope for minor differences in rule results - matching_amls = rule.rule_type == 'invoice_matching' and self.invoice_line_1.ids or [] - result_status = rule.rule_type == 'writeoff_suggestion' and {'status': 'write_off'} or {} + # Matching using the regex on payment_ref. + self.assertEqual(st_line._retrieve_partner(), self.partner_1) - match_result = {**result_status, 'aml_ids': matching_amls, 'model': rule, 'partner': self.partner_1} - no_match_result = {'aml_ids': []} + rule.partner_mapping_line_ids.narration_regex = ".*coincoin" - # Without mapping, there should be no match - self._check_statement_matching(rule, { - self.bank_line_1.id: no_match_result, - self.bank_line_2.id: no_match_result, - }, self.bank_st) + # No match because the narration is not matching the regex. + self.assertEqual(st_line._retrieve_partner(), self.env['res.partner']) - # We add some mapping for payment reference to rule_1 - rule.write({ - 'partner_mapping_line_ids': [(0, 0, { - 'partner_id': self.partner_1.id, - 'payment_ref_regex': 'toto.*', - })] - }) + st_line.narration = "42coincoin" - # bank_line_1 should now match - self._check_statement_matching(rule, { - self.bank_line_1.id: match_result, - self.bank_line_2.id: no_match_result, - }, self.bank_st) - - # If we now add a narration regex to the same mapping line, nothing should match - rule.partner_mapping_line_ids.write({'narration_regex': ".*coincoin"}) - self.bank_line_1.write({'narration': None}) # Reset from possible previous iteration - - self._check_statement_matching(rule, { - self.bank_line_1.id: no_match_result, - self.bank_line_2.id: no_match_result, - }, self.bank_st) - - # If we set the narration so that it matches the new mapping criterium, line_1 matches - self.bank_line_1.write({'narration': "42coincoin"}) - - self._check_statement_matching(rule, { - self.bank_line_1.id: match_result, - self.bank_line_2.id: no_match_result, - }, self.bank_st) + # Matching is back thanks to "coincoin". + self.assertEqual(st_line._retrieve_partner(), self.partner_1) def test_partner_name_in_communication(self): self.invoice_line_1.partner_id.write({'name': "Archibald Haddock"}) @@ -1076,9 +889,9 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): # bank_line_1 should match, as its communication contains the invoice's partner name self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'partner': self.bank_line_1.partner_id}, - self.bank_line_2.id: {'aml_ids': []}, - }, self.bank_st) + self.bank_line_1: {'amls': self.invoice_line_1, 'model': self.rule_1}, + self.bank_line_2: {}, + }) def test_partner_name_with_regexp_chars(self): self.invoice_line_1.partner_id.write({'name': "Archibald + Haddock"}) @@ -1088,9 +901,9 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): # The query should still work self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': [self.invoice_line_1.id], 'model': self.rule_1, 'partner': self.bank_line_1.partner_id}, - self.bank_line_2.id: {'aml_ids': []}, - }, self.bank_st) + self.bank_line_1: {'amls': self.invoice_line_1, 'model': self.rule_1}, + self.bank_line_2: {}, + }) def test_match_multi_currencies(self): ''' Ensure the matching of candidates is made using the right statement line currency. @@ -1182,41 +995,9 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): move_line_2 = move.line_ids.filtered(lambda line: line.debit == 14.0) self._check_statement_matching(matching_rule, { - statement_line.id: {'aml_ids': (move_line_1 + move_line_2).ids, 'model': matching_rule, 'partner': statement_line.partner_id} - }, statements=statement) - - @freeze_time('2020-01-01') - def test_inv_matching_with_write_off(self): - self.rule_1.payment_tolerance_param = 10.0 - self.bank_st.line_ids[1].unlink() # We don't need this one here - statement_line = self.bank_st.line_ids[0] - statement_line.write({ - 'payment_ref': self.invoice_line_1.move_id.payment_reference, - 'amount': 90, + statement_line: {'amls': move_line_1 + move_line_2, 'model': matching_rule} }) - # Test the invoice-matching part - self._check_statement_matching(self.rule_1, { - statement_line.id: {'aml_ids': self.invoice_line_1.ids, 'model': self.rule_1, 'partner': self.invoice_line_1.partner_id, 'status': 'write_off'}, - }, self.bank_st) - - # Test the write-off part - expected_write_off = { - 'balance': 10, - 'currency_id': self.company_data['currency'].id, - 'reconcile_model_id': self.rule_1.id, - 'account_id': self.current_assets_account.id, - } - - matching_result = self.rule_1._apply_rules(statement_line) - - self.assertEqual(len(matching_result[statement_line.id].get('write_off_vals', [])), 1, "Exactly one write-off line should be proposed.") - - full_write_off_dict = matching_result[statement_line.id]['write_off_vals'][0] - to_compare = {key: full_write_off_dict[key] for key in expected_write_off} - - self.assertDictEqual(expected_write_off, to_compare) - @freeze_time('2020-01-01') def test_matching_with_write_off_foreign_currency(self): journal_foreign_curr = self.company_data['default_journal_bank'].copy() @@ -1233,45 +1014,13 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): ) st_line = self._create_st_line(amount=100.0, payment_ref='123456', journal_id=journal_foreign_curr.id) - - reco_model._apply_rules(st_line) - - self.assertRecordValues(st_line, [{'is_reconciled': True}]) - self.assertRecordValues(st_line.line_ids.sorted('amount_currency'), [ - {'amount_currency': -100.0, 'currency_id': self.currency_data['currency'].id, 'balance': -50.0}, - {'amount_currency': 100.0, 'currency_id': self.currency_data['currency'].id, 'balance': 50.0}, - ]) - - def test_inv_matching_with_write_off_autoreconcile(self): - self.bank_line_1.amount = 95 - - self.rule_1.sequence = 2 - self.rule_1.auto_reconcile = True - self.rule_1.payment_tolerance_param = 10.0 - - self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: { - 'aml_ids': self.invoice_line_1.ids, - 'model': self.rule_1, - 'status': 'reconciled', - 'partner': self.bank_line_1.partner_id, + self._check_statement_matching(reco_model, { + st_line: { + 'model': reco_model, + 'status': 'write_off', + 'auto_reconcile': True, }, - self.bank_line_2.id: { - 'aml_ids': (self.invoice_line_2 + self.invoice_line_3).ids, - 'model': self.rule_1, - 'partner': self.bank_line_2.partner_id, - }, - }, statements=self.bank_st) - - # Check first line has been properly reconciled. - self.assertRecordValues(self.bank_line_1.line_ids, [ - # pylint: disable=bad-whitespace - {'partner_id': self.partner_1.id, 'debit': 95.0, 'credit': 0.0, 'account_id': self.bank_journal.default_account_id.id, 'reconciled': False}, - {'partner_id': self.partner_1.id, 'debit': 5.0, 'credit': 0.0, 'account_id': self.current_assets_account.id, 'reconciled': False}, - {'partner_id': self.partner_1.id, 'debit': 0.0, 'credit': 100.0, 'account_id': self.invoice_line_1.account_id.id, 'reconciled': True}, - ]) - - self.assertEqual(self.invoice_line_1.amount_residual, 0.0, "The invoice should have been fully reconciled") + }) def test_payment_similar_communications(self): def create_payment_line(amount, memo, partner): @@ -1306,8 +1055,8 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): self.rule_1.allow_payment_tolerance = False self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': (pmt_line_1 + pmt_line_2).ids, 'model': self.rule_1, 'partner': payment_partner}, - }, statements=self.bank_line_1.statement_id) + self.bank_line_1: {'amls': pmt_line_1 + pmt_line_2, 'model': self.rule_1, 'status': 'write_off'}, + }) def test_no_amount_check_keep_first(self): """ In case the reconciliation model doesn't check the total amount of the candidates, @@ -1320,9 +1069,13 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): self.bank_line_1.partner_id = None self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': []}, - self.bank_line_2.id: {'aml_ids': [self.invoice_line_1.id, self.invoice_line_2.id], 'model': self.rule_1, 'partner': self.bank_line_2.partner_id}, - }, statements=self.bank_st) + self.bank_line_1: {}, + self.bank_line_2: { + 'amls': self.invoice_line_1 + self.invoice_line_2, + 'model': self.rule_1, + 'status': 'write_off', + }, + }) def test_no_amount_check_exact_match(self): """ If a reconciliation model finds enough candidates for a full reconciliation, @@ -1335,6 +1088,10 @@ class TestReconciliationMatchingRules(AccountTestInvoicingCommon): self.bank_line_1.partner_id = None self._check_statement_matching(self.rule_1, { - self.bank_line_1.id: {'aml_ids': []}, - self.bank_line_2.id: {'aml_ids': [self.invoice_line_3.id], 'model': self.rule_1, 'partner': self.bank_line_2.partner_id}, - }, statements=self.bank_st) + self.bank_line_1: {}, + self.bank_line_2: { + 'amls': self.invoice_line_3, + 'model': self.rule_1, + 'status': 'write_off', + }, + }) diff --git a/addons/account/views/account_journal_dashboard_view.xml b/addons/account/views/account_journal_dashboard_view.xml index 8a10d35b92e..ef62a607322 100644 --- a/addons/account/views/account_journal_dashboard_view.xml +++ b/addons/account/views/account_journal_dashboard_view.xml @@ -71,13 +71,10 @@
View
-
+ - diff --git a/addons/account/views/account_move_views.xml b/addons/account/views/account_move_views.xml index c425e3a826f..9d59d6bab3c 100644 --- a/addons/account/views/account_move_views.xml +++ b/addons/account/views/account_move_views.xml @@ -799,7 +799,6 @@ - diff --git a/addons/hr_expense/tests/test_expenses.py b/addons/hr_expense/tests/test_expenses.py index ee60bf51aab..15341b08bed 100644 --- a/addons/hr_expense/tests/test_expenses.py +++ b/addons/hr_expense/tests/test_expenses.py @@ -59,7 +59,14 @@ class TestExpenses(TestExpenseCommon): ], }) statement.button_post() - statement.line_ids.reconcile([{'id': liquidity_lines1.id}, {'id': liquidity_lines2.id}]) + statement_line = statement.line_ids + + # Reconcile without the bank reconciliation widget since the widget is in enterprise. + _st_liquidity_lines, st_suspense_lines, _st_other_lines = statement_line\ + .with_context(skip_account_move_synchronization=True)\ + ._seek_for_lines() + st_suspense_lines.account_id = liquidity_lines1.account_id + (st_suspense_lines + liquidity_lines1 + liquidity_lines2).reconcile() self.assertEqual(expense_sheet.payment_state, 'paid', 'payment_state should be paid') diff --git a/addons/l10n_be/data/account_reconcile_model_template.xml b/addons/l10n_be/data/account_reconcile_model_template.xml index 61e52a5d873..f39e7c324e7 100644 --- a/addons/l10n_be/data/account_reconcile_model_template.xml +++ b/addons/l10n_be/data/account_reconcile_model_template.xml @@ -46,16 +46,4 @@ 100 Virements internes - - - Compte Attente - - - - - - percentage - 100 - - diff --git a/odoo/addons/test_main_flows/static/tests/tours/main_flow.js b/odoo/addons/test_main_flows/static/tests/tours/main_flow.js index 5622c97b63b..5496d3c55af 100644 --- a/odoo/addons/test_main_flows/static/tests/tours/main_flow.js +++ b/odoo/addons/test_main_flows/static/tests/tours/main_flow.js @@ -1065,16 +1065,14 @@ tour.stepUtils.autoExpandMoreButtons('.o_control_panel .breadcrumb:contains("the position: 'bottom', }, ...tour.stepUtils.statusbarButtonsSteps('Post', _t('Processing'), ".breadcrumb-item.active:contains('the_flow.statement')").map(tour.stepUtils.editionEnterpriseModifier), -...tour.stepUtils.statusbarButtonsSteps('Reconcile', _t('Reconcile'), ".o_statusbar_status .btn.dropdown-toggle:contains(Processing)").map(tour.stepUtils.editionEnterpriseModifier), +...tour.stepUtils.statusbarButtonsSteps('Reconcile', _t('Reconcile'), ".o_statusbar_status .btn.dropdown-toggle:contains(Processing)") + .map(function(step){return {mobile: false, ...step}}) + .map(tour.stepUtils.editionEnterpriseModifier), { + mobile: false, edition: "enterprise", - trigger: "button.o_reconcile, button.o_validate", + trigger: "button[name='button_validate']", content: Markup(_t('

Click on Reconcile

')), position: "right", -}, { - edition: "enterprise", - trigger: ".button_back_to_statement", - content: Markup(_t('

Close this statement.

')), - position: "bottom", }]); });