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
-
+ -
- Operations -
Cust. Payments
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", }]); });