diff --git a/addons/account/models/account_move.py b/addons/account/models/account_move.py index 0d904512283..c38dcfaf554 100644 --- a/addons/account/models/account_move.py +++ b/addons/account/models/account_move.py @@ -648,47 +648,6 @@ class AccountMove(models.Model): if fiscal_position: self.fiscal_position_id = fiscal_position - @api.model - def _get_tax_grouping_key_from_tax_line(self, tax_line): - ''' Create the dictionary based on a tax line that will be used as key to group taxes together. - /!\ Must be consistent with '_get_tax_grouping_key_from_base_line'. - :param tax_line: An account.move.line being a tax line (with 'tax_repartition_line_id' set then). - :return: A dictionary containing all fields on which the tax will be grouped. - ''' - return { - 'tax_repartition_line_id': tax_line.tax_repartition_line_id.id, - 'group_tax_id': tax_line.group_tax_id.id, - 'account_id': tax_line.account_id.id, - 'currency_id': tax_line.currency_id.id, - 'analytic_tag_ids': [(6, 0, tax_line.tax_line_id.analytic and tax_line.analytic_tag_ids.ids or [])], - 'analytic_account_id': tax_line.tax_line_id.analytic and tax_line.analytic_account_id.id, - 'tax_ids': [(6, 0, tax_line.tax_ids.ids)], - 'tax_tag_ids': [(6, 0, tax_line.tax_tag_ids.ids)], - 'partner_id': tax_line.partner_id.id, - } - - @api.model - def _get_tax_grouping_key_from_base_line(self, base_line, tax_vals): - ''' Create the dictionary based on a base line that will be used as key to group taxes together. - /!\ Must be consistent with '_get_tax_grouping_key_from_tax_line'. - :param base_line: An account.move.line being a base line (that could contains something in 'tax_ids'). - :param tax_vals: An element of compute_all(...)['taxes']. - :return: A dictionary containing all fields on which the tax will be grouped. - ''' - tax_repartition_line = self.env['account.tax.repartition.line'].browse(tax_vals['tax_repartition_line_id']) - account = base_line._get_default_tax_account(tax_repartition_line) or base_line.account_id - return { - 'tax_repartition_line_id': tax_vals['tax_repartition_line_id'], - 'group_tax_id': tax_vals['group'].id if tax_vals['group'] else False, - 'account_id': account.id, - 'currency_id': base_line.currency_id.id, - 'analytic_tag_ids': [(6, 0, tax_vals['analytic'] and base_line.analytic_tag_ids.ids or [])], - 'analytic_account_id': tax_vals['analytic'] and base_line.analytic_account_id.id, - 'tax_ids': [(6, 0, tax_vals['tax_ids'])], - 'tax_tag_ids': [(6, 0, tax_vals['tag_ids'])], - 'partner_id': base_line.partner_id.id, - } - def _get_tax_force_sign(self): """ The sign must be forced to a negative sign in case the balance is on credit to avoid negatif taxes amount. @@ -700,169 +659,111 @@ class AccountMove(models.Model): self.ensure_one() return -1 if self.move_type in ('out_invoice', 'in_refund', 'out_receipt') else 1 - def _preprocess_taxes_map(self, taxes_map): - """ Useful in case we want to pre-process taxes_map """ - return taxes_map - def _recompute_tax_lines(self, recompute_tax_base_amount=False): """ Compute the dynamic tax lines of the journal entry. - :param recompute_tax_base_amount: Flag forcing only the recomputation of the `tax_base_amount` field. + :param recompute_tax_base_amount: recompute only the tax_base_amount field. """ self.ensure_one() in_draft_mode = self != self._origin + sign = -1 if self.is_inbound(include_receipts=True) else 1 - def _serialize_tax_grouping_key(grouping_dict): - ''' Serialize the dictionary values to be used in the taxes_map. - :param grouping_dict: The values returned by '_get_tax_grouping_key_from_tax_line' or '_get_tax_grouping_key_from_base_line'. - :return: A string representing the values. - ''' - return '-'.join(str(v) for v in grouping_dict.values()) + def fill_accounting_tax_vals(update_vals): + currency = self.env['res.currency'].browse(update_vals['currency_id']) + tax_amount = update_vals.pop('tax_amount') - def _compute_base_line_taxes(base_line): - ''' Compute taxes amounts both in company currency / foreign currency as the ratio between - amount_currency & balance could not be the same as the expected currency rate. - The 'amount_currency' value will be set on compute_all(...)['taxes'] in multi-currency. - :param base_line: The account.move.line owning the taxes. - :return: The result of the compute_all method. - ''' - move = base_line.move_id - - if move.is_invoice(include_receipts=True): - handle_price_include = True - sign = -1 if move.is_inbound() else 1 - quantity = base_line.quantity - is_refund = move.move_type in ('out_refund', 'in_refund') - price_unit_wo_discount = sign * base_line.price_unit * (1 - (base_line.discount / 100.0)) + tax_rep = self.env['account.tax.repartition.line'].browse(update_vals['tax_repartition_line_id']) + tax_id = update_vals.pop('tax_id') + if tax_id == tax_rep.tax_id.id: + group_tax = None else: - handle_price_include = False - quantity = 1.0 - tax_type = base_line.tax_ids[0].type_tax_use if base_line.tax_ids else None - is_refund = (tax_type == 'sale' and base_line.debit) or (tax_type == 'purchase' and base_line.credit) - price_unit_wo_discount = base_line.amount_currency + group_tax = self.env['account.tax'].browse(tax_id) - return base_line.tax_ids._origin.with_context(force_sign=move._get_tax_force_sign()).compute_all( - price_unit_wo_discount, - currency=base_line.currency_id, - quantity=quantity, - product=base_line.product_id, - partner=base_line.partner_id, - is_refund=is_refund, - handle_price_include=handle_price_include, - include_caba_tags=move.always_tax_exigible, - ) - - taxes_map = {} - - # ==== Add tax lines ==== - to_remove = self.env['account.move.line'] - for line in self.line_ids.filtered('tax_repartition_line_id'): - grouping_dict = self._get_tax_grouping_key_from_tax_line(line) - grouping_key = _serialize_tax_grouping_key(grouping_dict) - if grouping_key in taxes_map: - # A line with the same key does already exist, we only need one - # to modify it; we have to drop this one. - to_remove += line - else: - taxes_map[grouping_key] = { - 'tax_line': line, - 'amount': 0.0, - 'tax_base_amount': 0.0, - 'grouping_dict': False, - } - if not recompute_tax_base_amount: - self.line_ids -= to_remove - - # ==== Mount base lines ==== - for line in self.line_ids.filtered(lambda line: not line.tax_repartition_line_id): - # Don't call compute_all if there is no tax. - if not line.tax_ids: - if not recompute_tax_base_amount: - line.tax_tag_ids = [(5, 0, 0)] - continue - - compute_all_vals = _compute_base_line_taxes(line) - - # Assign tags on base line - if not recompute_tax_base_amount: - line.tax_tag_ids = compute_all_vals['base_tags'] or [(5, 0, 0)] - - for tax_vals in compute_all_vals['taxes']: - grouping_dict = self._get_tax_grouping_key_from_base_line(line, tax_vals) - grouping_key = _serialize_tax_grouping_key(grouping_dict) - - tax_repartition_line = self.env['account.tax.repartition.line'].browse(tax_vals['tax_repartition_line_id']) - tax = tax_repartition_line.invoice_tax_id or tax_repartition_line.refund_tax_id - - taxes_map_entry = taxes_map.setdefault(grouping_key, { - 'tax_line': None, - 'amount': 0.0, - 'tax_base_amount': 0.0, - 'grouping_dict': False, - }) - taxes_map_entry['amount'] += tax_vals['amount'] - taxes_map_entry['tax_base_amount'] += self._get_base_amount_to_display(tax_vals['base'], tax_repartition_line, tax_vals['group']) - taxes_map_entry['grouping_dict'] = grouping_dict - - # ==== Pre-process taxes_map ==== - taxes_map = self._preprocess_taxes_map(taxes_map) - - # ==== Process taxes_map ==== - for taxes_map_entry in taxes_map.values(): - # The tax line is no longer used in any base lines, drop it. - if taxes_map_entry['tax_line'] and not taxes_map_entry['grouping_dict']: - if not recompute_tax_base_amount: - self.line_ids -= taxes_map_entry['tax_line'] - continue - - currency = self.env['res.currency'].browse(taxes_map_entry['grouping_dict']['currency_id']) - - # tax_base_amount field is expressed using the company currency. - tax_base_amount = currency._convert(taxes_map_entry['tax_base_amount'], self.company_currency_id, self.company_id, self.date or fields.Date.context_today(self)) - - # Recompute only the tax_base_amount. - if recompute_tax_base_amount: - if taxes_map_entry['tax_line']: - taxes_map_entry['tax_line'].tax_base_amount = tax_base_amount - continue - - balance = currency._convert( - taxes_map_entry['amount'], + tax_base_amount = sign * currency._convert( + update_vals['tax_base_amount'], self.company_currency_id, self.company_id, self.date or fields.Date.context_today(self), ) - currency_id = taxes_map_entry['grouping_dict']['currency_id'] - to_write_on_line = { - 'amount_currency': taxes_map_entry['amount'], - 'currency_id': currency_id, - 'debit': self._get_debit_from_balance(balance, currency_id), - 'credit': self._get_credit_from_balance(balance, currency_id), + tax_base_amount = self._get_base_amount_to_display(tax_base_amount, tax_rep, group_tax) + amount_currency = sign * tax_amount + balance = currency._convert( + amount_currency, + self.company_currency_id, + self.company_id, + self.date or fields.Date.context_today(self), + ) + + update_vals.update({ + 'name': tax_rep.tax_id.name, 'tax_base_amount': tax_base_amount, - } + 'amount_currency': amount_currency, + 'debit': self._get_debit_from_balance(balance, currency.id), + 'credit': self._get_credit_from_balance(balance, currency.id), + 'exclude_from_invoice_tab': True, + 'currency_id': currency.id, + 'group_tax_id': group_tax.id if group_tax else None, + }) - if taxes_map_entry['tax_line']: - # Update an existing tax line. - taxes_map_entry['tax_line'].update(to_write_on_line) + line_ids_commands = [] + is_invoice = self.is_invoice(include_receipts=True) + + if is_invoice: + base_lines = self.line_ids.filtered(lambda x: not x.display_type and not x.exclude_from_invoice_tab) + force_sign = 1 + else: + base_lines = self.line_ids.filtered(lambda x: not x.display_type and not x.tax_repartition_line_id) + force_sign = self._get_tax_force_sign() + tax_lines = self.line_ids.filtered(lambda x: not x.display_type and x.tax_repartition_line_id) + + tax_results = self.env['account.tax'].with_context(force_sign=force_sign)._compute_taxes( + [x._convert_to_tax_base_line_dict() for x in base_lines], + tax_lines=[x._convert_to_tax_line_dict() for x in tax_lines], + handle_price_include=is_invoice, + include_caba_tags=self.always_tax_exigible, + ) + + # ==== BASE LINES ===== + + if not recompute_tax_base_amount: + + # Recompute 'tax_tag_ids'. + for base_line_vals, to_update in tax_results['base_lines_to_update']: + base_line = base_line_vals['record'] + line_ids_commands.append(Command.update(base_line.id, { + 'tax_tag_ids': to_update['tax_tag_ids'], + })) + + # ===== TAX LINES ===== + + # Tax lines that are no longer neeeded. + if not recompute_tax_base_amount: + for tax_line_vals in tax_results['tax_lines_to_delete']: + tax_line = tax_line_vals['record'] + line_ids_commands.append(Command.unlink(tax_line.id)) + + # Newly created tax lines. + if not recompute_tax_base_amount: + for tax_line_vals in tax_results['tax_lines_to_add']: + fill_accounting_tax_vals(tax_line_vals) + line_ids_commands.append(Command.create(tax_line_vals)) + + # Update of existing tax lines. + for tax_line_vals, to_update in tax_results['tax_lines_to_update']: + tax_line = tax_line_vals['record'] + fill_accounting_tax_vals(to_update) + + if recompute_tax_base_amount: + fields_to_update = ('tax_base_amount',) else: - # Create a new tax line. - create_method = in_draft_mode and self.env['account.move.line'].new or self.env['account.move.line'].create - tax_repartition_line_id = taxes_map_entry['grouping_dict']['tax_repartition_line_id'] - tax_repartition_line = self.env['account.tax.repartition.line'].browse(tax_repartition_line_id) - tax = tax_repartition_line.invoice_tax_id or tax_repartition_line.refund_tax_id - taxes_map_entry['tax_line'] = create_method({ - **to_write_on_line, - 'name': tax.name, - 'move_id': self.id, - 'company_id': line.company_id.id, - 'company_currency_id': line.company_currency_id.id, - 'tax_base_amount': tax_base_amount, - 'exclude_from_invoice_tab': True, - **taxes_map_entry['grouping_dict'], - }) + fields_to_update = ('tax_base_amount', 'amount_currency', 'debit', 'credit') + line_ids_commands.append(Command.update(tax_line.id, {k: v for k, v in to_update.items() if k in fields_to_update})) - if in_draft_mode: - taxes_map_entry['tax_line'].update(taxes_map_entry['tax_line']._get_fields_onchange_balance(force_computation=True)) + (self.update if in_draft_mode else self.write)({'line_ids': line_ids_commands}) + + if in_draft_mode: + for tax_line in tax_lines: + tax_line.update(tax_line._get_fields_onchange_balance(force_computation=True)) @api.model def _get_base_amount_to_display(self, base_amount, tax_rep_ln, parent_tax_group=None): @@ -1702,243 +1603,21 @@ class AccountMove(models.Model): Only set on invoices. """ for move in self: - if not move.is_invoice(include_receipts=True): + if move.is_invoice(include_receipts=True): + base_lines = move.line_ids.filtered(lambda x: not x.display_type and not x.exclude_from_invoice_tab) + tax_lines = move.line_ids.filtered(lambda x: not x.display_type and x.tax_repartition_line_id) + + tax_totals = self.env['account.tax']._prepare_tax_totals_json( + [x._convert_to_tax_base_line_dict() for x in base_lines], + move.currency_id, + tax_lines=[x._convert_to_tax_line_dict() for x in tax_lines], + ) + tax_totals['allow_tax_edition'] = move.is_purchase_document(include_receipts=True) + + move.tax_totals_json = json.dumps(tax_totals) + else: # Non-invoice moves don't support that field (because of multicurrency: all lines of the invoice share the same currency) move.tax_totals_json = None - continue - - tax_lines_data = move._prepare_tax_lines_data_for_totals_from_invoice() - - move.tax_totals_json = json.dumps({ - **self._get_tax_totals(move.partner_id, tax_lines_data, move.amount_total, move.amount_untaxed, move.currency_id), - 'allow_tax_edition': move.is_purchase_document(include_receipts=False) and move.state == 'draft', - }) - - def _prepare_tax_lines_data_for_totals_from_invoice(self, tax_line_id_filter=None, tax_ids_filter=None): - """ Prepares data to be passed as tax_lines_data parameter of _get_tax_totals() from an invoice. - - NOTE: tax_line_id_filter and tax_ids_filter are used in l10n_latam to restrict the taxes with consider - in the totals. - - :param tax_line_id_filter: a function(aml, tax) returning true if tax should be considered on tax move line aml. - :param tax_ids_filter: a function(aml, taxes) returning true if taxes should be considered on base move line aml. - - :return: A list of dict in the format described in _get_tax_totals's tax_lines_data's docstring. - """ - self.ensure_one() - - tax_line_id_filter = tax_line_id_filter or (lambda aml, tax: True) - tax_ids_filter = tax_ids_filter or (lambda aml, tax: True) - - balance_multiplicator = -1 if self.is_inbound() else 1 - tax_lines_data = [] - - for line in self.line_ids: - if line.tax_line_id and tax_line_id_filter(line, line.tax_line_id): - tax_lines_data.append({ - 'line_key': 'tax_line_%s' % line.id, - 'tax_amount': line.amount_currency * balance_multiplicator, - 'tax': line.tax_line_id, - }) - - if line.tax_ids: - for base_tax in line.tax_ids.flatten_taxes_hierarchy(): - if tax_ids_filter(line, base_tax): - tax_lines_data.append({ - 'line_key': 'base_line_%s' % line.id, - 'base_amount': line.amount_currency * balance_multiplicator, - 'tax': base_tax, - 'tax_affecting_base': line.tax_line_id, - }) - - return tax_lines_data - - @api.model - def _prepare_tax_lines_data_for_totals_from_object(self, object_lines, tax_results_function): - """ Prepares data to be passed as tax_lines_data parameter of _get_tax_totals() from any - object using taxes. This helper is intended for purchase.order and sale.order, as a common - function centralizing their behavior. - - :param object_lines: A list of records corresponding to the sub-objects generating the tax totals - (sale.order.line or purchase.order.line, for example) - - :param tax_results_function: A function to be called to get the results of the tax computation for a - line in object_lines. It takes the object line as its only parameter - and returns a dict in the same format as account.tax's compute_all - (most probably after calling it with the right parameters). - - :return: A list of dict in the format described in _get_tax_totals's tax_lines_data's docstring. - """ - tax_lines_data = [] - - for line in object_lines: - tax_results = tax_results_function(line) - - for tax_result in tax_results['taxes']: - current_tax = self.env['account.tax'].browse(tax_result['id']) - - # Tax line - tax_lines_data.append({ - 'line_key': f"tax_line_{line.id}_{tax_result['id']}", - 'tax_amount': tax_result['amount'], - 'tax': current_tax, - }) - - # Base for this tax line - tax_lines_data.append({ - 'line_key': 'base_line_%s' % line.id, - 'base_amount': tax_results['total_excluded'], - 'tax': current_tax, - }) - - # Base for the taxes whose base is affected by this tax line - if tax_result['tax_ids']: - affected_taxes = self.env['account.tax'].browse(tax_result['tax_ids']) - for affected_tax in affected_taxes: - tax_lines_data.append({ - 'line_key': 'affecting_base_line_%s_%s' % (line.id, tax_result['id']), - 'base_amount': tax_result['amount'], - 'tax': affected_tax, - 'tax_affecting_base': current_tax, - }) - - return tax_lines_data - - @api.model - def _get_tax_totals(self, partner, tax_lines_data, amount_total, amount_untaxed, currency): - """ Compute the tax totals for the provided data. - - :param partner: The partner to compute totals for - :param tax_lines_data: All the data about the base and tax lines as a list of dictionaries. - Each dictionary represents an amount that needs to be added to either a tax base or amount. - A tax amount looks like: - { - 'line_key': unique identifier, - 'tax_amount': the amount computed for this tax - 'tax': the account.tax object this tax line was made from - } - For base amounts: - { - 'line_key': unique identifier, - 'base_amount': the amount to add to the base of the tax - 'tax': the tax basing itself on this amount - 'tax_affecting_base': (optional key) the tax whose tax line is having the impact - denoted by 'base_amount' on the base of the tax, in case of taxes - affecting the base of subsequent ones. - } - :param amount_total: Total amount, with taxes. - :param amount_untaxed: Total amount without taxes. - :param currency: The currency in which the amounts are computed. - - :return: A dictionary in the following form: - { - 'amount_total': The total amount to be displayed on the document, including every total types. - 'amount_untaxed': The untaxed amount to be displayed on the document. - 'formatted_amount_total': Same as amount_total, but as a string formatted accordingly with partner's locale. - 'formatted_amount_untaxed': Same as amount_untaxed, but as a string formatted accordingly with partner's locale. - 'allow_tax_edition': True if the user should have the ability to manually edit the tax amounts by group - to fix rounding errors. - 'groups_by_subtotals': A dictionary formed liked {'subtotal': groups_data} - Where total_type is a subtotal name defined on a tax group, or the default one: 'Untaxed Amount'. - And groups_data is a list of dict in the following form: - { - 'tax_group_name': The name of the tax groups this total is made for. - 'tax_group_amount': The total tax amount in this tax group. - 'tax_group_base_amount': The base amount for this tax group. - 'formatted_tax_group_amount': Same as tax_group_amount, but as a string - formatted accordingly with partner's locale. - 'formatted_tax_group_base_amount': Same as tax_group_base_amount, but as a string - formatted accordingly with partner's locale. - 'tax_group_id': The id of the tax group corresponding to this dict. - 'group_key': A unique key identifying this total dict, - } - 'subtotals': A list of dictionaries in the following form, one for each subtotal in groups_by_subtotals' keys - { - 'name': The name of the subtotal - 'amount': The total amount for this subtotal, summing all - the tax groups belonging to preceding subtotals and the base amount - 'formatted_amount': Same as amount, but as a string - formatted accordingly with partner's locale. - } - } - """ - account_tax = self.env['account.tax'] - - grouped_taxes = defaultdict(lambda: defaultdict(lambda: {'base_amount': 0.0, 'tax_amount': 0.0, 'base_line_keys': set()})) - subtotal_priorities = {} - for line_data in tax_lines_data: - tax_group = line_data['tax'].tax_group_id - - # Update subtotals priorities - if tax_group.preceding_subtotal: - subtotal_title = tax_group.preceding_subtotal - new_priority = tax_group.sequence - else: - # When needed, the default subtotal is always the most prioritary - subtotal_title = _("Untaxed Amount") - new_priority = 0 - - if subtotal_title not in subtotal_priorities or new_priority < subtotal_priorities[subtotal_title]: - subtotal_priorities[subtotal_title] = new_priority - - # Update tax data - tax_group_vals = grouped_taxes[subtotal_title][tax_group] - - if 'base_amount' in line_data: - # Base line - if tax_group == line_data.get('tax_affecting_base', account_tax).tax_group_id: - # In case the base has a tax_line_id belonging to the same group as the base tax, - # the base for the group will be computed by the base tax's original line (the one with tax_ids and no tax_line_id) - continue - - if line_data['line_key'] not in tax_group_vals['base_line_keys']: - # If the base line hasn't been taken into account yet, at its amount to the base total. - tax_group_vals['base_line_keys'].add(line_data['line_key']) - tax_group_vals['base_amount'] += line_data['base_amount'] - - else: - # Tax line - tax_group_vals['tax_amount'] += line_data['tax_amount'] - - # Compute groups_by_subtotal - groups_by_subtotal = {} - for subtotal_title, groups in grouped_taxes.items(): - groups_vals = [{ - 'tax_group_name': group.name, - 'tax_group_amount': amounts['tax_amount'], - 'tax_group_base_amount': amounts['base_amount'], - 'formatted_tax_group_amount': formatLang(self.env, amounts['tax_amount'], currency_obj=currency), - 'formatted_tax_group_base_amount': formatLang(self.env, amounts['base_amount'], currency_obj=currency), - 'tax_group_id': group.id, - 'group_key': '%s-%s' %(subtotal_title, group.id), - } for group, amounts in sorted(groups.items(), key=lambda l: l[0].sequence)] - - groups_by_subtotal[subtotal_title] = groups_vals - - # Compute subtotals - subtotals_list = [] # List, so that we preserve their order - previous_subtotals_tax_amount = 0 - for subtotal_title in sorted((sub for sub in subtotal_priorities), key=lambda x: subtotal_priorities[x]): - subtotal_value = amount_untaxed + previous_subtotals_tax_amount - subtotals_list.append({ - 'name': subtotal_title, - 'amount': subtotal_value, - 'formatted_amount': formatLang(self.env, subtotal_value, currency_obj=currency), - }) - - subtotal_tax_amount = sum(group_val['tax_group_amount'] for group_val in groups_by_subtotal[subtotal_title]) - previous_subtotals_tax_amount += subtotal_tax_amount - - # Assign json-formatted result to the field - return { - 'amount_total': amount_total, - 'amount_untaxed': amount_untaxed, - 'formatted_amount_total': formatLang(self.env, amount_total, currency_obj=currency), - 'formatted_amount_untaxed': formatLang(self.env, amount_untaxed, currency_obj=currency), - 'groups_by_subtotal': groups_by_subtotal, - 'subtotals': subtotals_list, - 'allow_tax_edition': False, - } @api.depends('date', 'line_ids.debit', 'line_ids.credit', 'line_ids.tax_line_id', 'line_ids.tax_ids', 'line_ids.tax_tag_ids') def _compute_tax_lock_date_message(self): @@ -5430,6 +5109,61 @@ class AccountMoveLine(models.Model): # MISC # ------------------------------------------------------------------------- + def _convert_to_tax_base_line_dict(self): + """ Convert the current record to a dictionary in order to use the generic taxes computation method + defined on account.tax. + + :return: A python dictionary. + """ + self.ensure_one() + is_invoice = self.move_id.is_invoice(include_receipts=True) + sign = -1 if self.move_id.is_inbound(include_receipts=True) else 1 + + if is_invoice: + is_refund = self.move_id.move_type in ('out_refund', 'in_refund') + else: + tax_type = self.tax_ids[0].type_tax_use if self.tax_ids else None + is_refund = (tax_type == 'sale' and self.balance > 0.0) or (tax_type == 'purchase' and self.balance < 0.0) + + return self.env['account.tax']._convert_to_tax_base_line_dict( + self, + partner=self.partner_id, + currency=self.currency_id, + product=self.product_id, + taxes=self.tax_ids, + price_unit=self.price_unit if is_invoice else self.amount_currency, + quantity=self.quantity if is_invoice else 1.0, + discount=self.discount if is_invoice else 0.0, + account=self.account_id, + analytic_account=self.analytic_account_id, + analytic_tags=self.analytic_tag_ids, + price_subtotal=sign * self.amount_currency, + is_refund=is_refund, + ) + + def _convert_to_tax_line_dict(self): + """ Convert the current record to a dictionary in order to use the generic taxes computation method + defined on account.tax. + + :return: A python dictionary. + """ + self.ensure_one() + sign = -1 if self.move_id.is_inbound(include_receipts=True) else 1 + + return self.env['account.tax']._convert_to_tax_line_dict( + self, + partner=self.partner_id, + currency=self.currency_id, + taxes=self.tax_ids, + tax_tags=self.tax_tag_ids, + tax_repartition_line=self.tax_repartition_line_id, + group_tax=self.group_tax_id, + account=self.account_id, + analytic_account=self.analytic_account_id, + analytic_tags=self.analytic_tag_ids, + tax_amount=sign * self.amount_currency, + ) + def _get_analytic_tag_ids(self): self.ensure_one() return self.analytic_tag_ids.filtered(lambda r: not r.active_analytic_distribution).ids diff --git a/addons/account/models/account_tax.py b/addons/account/models/account_tax.py index 5ae9925b48f..e5c3fc8574f 100644 --- a/addons/account/models/account_tax.py +++ b/addons/account/models/account_tax.py @@ -1,11 +1,13 @@ # -*- coding: utf-8 -*- -from odoo import api, fields, models, _ +from odoo import api, fields, models, _, Command from odoo.osv import expression from odoo.tools.float_utils import float_round as round from odoo.exceptions import UserError, ValidationError +from odoo.tools.misc import formatLang +from odoo.tools import frozendict +from collections import defaultdict import math -import logging TYPE_TAX_USE = [ @@ -643,7 +645,7 @@ class AccountTax(models.Model): 'amount': sign * line_amount, 'base': round(sign * tax_base_amount, precision_rounding=prec), 'sequence': tax.sequence, - 'account_id': tax.cash_basis_transition_account_id.id if tax.tax_exigibility == 'on_payment' else repartition_line.account_id.id, + 'account_id': repartition_line._get_aml_target_tax_account().id, 'analytic': tax.analytic, 'price_include': price_include, 'tax_exigibility': tax.tax_exigibility, @@ -679,6 +681,439 @@ class AccountTax(models.Model): 'total_void': sign * currency.round(total_void), } + @api.model + def _convert_to_tax_base_line_dict( + self, base_line, + partner=None, currency=None, product=None, taxes=None, price_unit=None, quantity=None, + discount=None, account=None, analytic_account=None, analytic_tags=None, price_subtotal=None, + is_refund=False, + ): + return { + 'record': base_line, + 'partner': partner or self.env['res.partner'], + 'currency': currency or self.env['res.currency'], + 'product': product or self.env['product.product'], + 'taxes': taxes or self.env['account.tax'], + 'price_unit': price_unit or 0.0, + 'quantity': quantity or 0.0, + 'discount': discount or 0.0, + 'account': account or self.env['account.account'], + 'analytic_account': analytic_account or self.env['account.analytic.account'], + 'analytic_tags': analytic_tags or self.env['account.analytic.tag'], + 'price_subtotal': price_subtotal or 0.0, + 'is_refund': is_refund, + } + + @api.model + def _convert_to_tax_line_dict( + self, tax_line, + partner=None, currency=None, taxes=None, tax_tags=None, tax_repartition_line=None, + group_tax=None, account=None, analytic_account=None, analytic_tags=None, tax_amount=None, + ): + return { + 'record': tax_line, + 'partner': partner or self.env['res.partner'], + 'currency': currency or self.env['res.currency'], + 'taxes': taxes or self.env['account.tax'], + 'tax_tags': tax_tags or self.env['account.account.tag'], + 'tax_repartition_line': tax_repartition_line or self.env['account.tax.repartition.line'], + 'group_tax': group_tax or self.env['account.tax'], + 'account': account or self.env['account.account'], + 'analytic_account': analytic_account or self.env['account.analytic.account'], + 'analytic_tags': analytic_tags or self.env['account.analytic.tag'], + 'tax_amount': tax_amount or 0.0, + } + + @api.model + def _get_generation_dict_from_base_line(self, line_vals, tax_vals): + """ Take a tax results returned by the taxes computation method and return a dictionary representing the way + the tax amounts will be grouped together. To do so, the dictionary will be converted into a string key. + Then, the existing tax lines sharing the same key will be updated and the missing ones will be created. + + :param line_vals: A python dict returned by '_convert_to_tax_base_line_dict'. + :param tax_vals: A python dict returned by 'compute_all' under the 'taxes' key. + :return: A python dict. + """ + tax_repartition_line = self.env['account.tax.repartition.line'].browse(tax_vals['tax_repartition_line_id']) + tax_account = tax_repartition_line._get_aml_target_tax_account() or line_vals['account'] + return { + 'account_id': tax_account.id, + 'currency_id': line_vals['currency'].id, + 'partner_id': line_vals['partner'].id, + 'tax_repartition_line_id': tax_repartition_line.id, + 'tax_ids': [Command.set(tax_vals['tax_ids'])], + 'tax_tag_ids': [Command.set(tax_vals['tag_ids'])], + 'tax_id': tax_vals['group'].id if tax_vals['group'] else tax_vals['id'], + 'analytic_tag_ids': [Command.set(line_vals['analytic_tags'].ids if tax_vals['analytic'] else [])], + 'analytic_account_id': line_vals['analytic_account'].id if tax_vals['analytic'] else False, + } + + @api.model + def _get_generation_dict_from_tax_line(self, line_vals): + """ Turn the values corresponding to a tax line and convert it into a dictionary. The dictionary will be + converted into a string key. This allows updating the existing tax lines instead of creating new ones + everytime. + + :param line_vals: A python dict returned by '_convert_to_tax_line_dict'. + :return: A python dict representing the grouping key used to update an existing tax line. + """ + tax = line_vals['tax_repartition_line'].tax_id + return { + 'account_id': line_vals['account'].id, + 'currency_id': line_vals['currency'].id, + 'partner_id': line_vals['partner'].id, + 'tax_repartition_line_id': line_vals['tax_repartition_line'].id, + 'tax_ids': [Command.set(line_vals['taxes'].ids)], + 'tax_tag_ids': [Command.set(line_vals['tax_tags'].ids)], + 'tax_id': (line_vals['group_tax'] or tax).id, + 'analytic_tag_ids': [Command.set(line_vals['analytic_tags'].ids if tax.analytic else [])], + 'analytic_account_id': line_vals['analytic_account'].id if tax.analytic else False, + } + + def _compute_taxes(self, base_lines, tax_lines=None, + handle_price_include=True, include_caba_tags=False): + """ Generic method to compute the taxes for different business models. + + :param base_lines: A list of python dictionaries created using the '_convert_to_tax_base_line_dict' method. + :param tax_lines: A list of python dictionaries created using the '_convert_to_tax_line_dict' method. + :param handle_price_include: Manage the price-included taxes. + :param include_caba_tags: Manage tags for taxes being exigible on_payment. + :return: A python dictionary containing: + + The complete diff on tax lines if 'tax_lines' is passed as parameter: + * tax_lines_to_add: To create new tax lines. + * tax_lines_to_delete: To track the tax lines that are no longer used. + * tax_lines_to_update: The values to update the existing tax lines. + + * base_lines_to_update: The values to update the existing base lines: + * tax_tag_ids: The tags related to taxes. + * price_subtotal: The amount without tax. + * price_total: The amount with taxes. + + * totals: A mapping for each involved currency to: + * amount_untaxed: The base amount without tax. + * amount_tax: The total tax amount. + """ + res = { + 'tax_lines_to_add': [], + 'tax_lines_to_delete': [], + 'tax_lines_to_update': [], + 'base_lines_to_update': [], + 'totals': defaultdict(lambda: { + 'amount_untaxed': 0.0, + 'amount_tax': 0.0, + }), + } + + # ========================================================================================= + # BASE LINES + # For each base line, populate 'base_lines_to_update'. + # Compute 'tax_base_amount'/'tax_amount' for each pair + # using the grouping key generated by the '_get_generation_dict_from_base_line' method. + # ========================================================================================= + + base_line_map = {} + for line_vals in base_lines: + price_unit_after_discount = line_vals['price_unit'] * (1 - (line_vals['discount'] / 100.0)) + taxes = line_vals['taxes']._origin + currency = line_vals['currency'] or self.env.company.currency_id + + if taxes: + taxes_res = taxes.compute_all( + price_unit_after_discount, + currency=currency, + quantity=line_vals['quantity'], + product=line_vals['product'], + partner=line_vals['partner'], + is_refund=line_vals['is_refund'], + handle_price_include=handle_price_include, + include_caba_tags=include_caba_tags, + ) + + to_update_vals = { + 'tax_tag_ids': [Command.set(taxes_res['base_tags'])], + 'price_subtotal': taxes_res['total_excluded'], + 'price_total': taxes_res['total_included'], + } + + for tax_res in taxes_res['taxes']: + grouping_dict = self._get_generation_dict_from_base_line(line_vals, tax_res) + tax_res['grouping_dict'] = grouping_dict + + grouping_key = frozendict(grouping_dict) + + base_line_map.setdefault(grouping_key, { + **tax_res['grouping_dict'], + 'tax_base_amount': 0.0, + 'tax_amount': 0.0, + }) + base_line_map[grouping_key]['tax_base_amount'] += tax_res['base'] + base_line_map[grouping_key]['tax_amount'] += tax_res['amount'] + else: + price_subtotal = currency.round(price_unit_after_discount * line_vals['quantity']) + to_update_vals = { + 'tax_tag_ids': [], + 'price_subtotal': price_subtotal, + 'price_total': price_subtotal, + } + + res['base_lines_to_update'].append((line_vals, to_update_vals)) + res['totals'][currency]['amount_untaxed'] += to_update_vals['price_subtotal'] + + # ========================================================================================= + # TAX LINES + # Map each existing tax lines using the grouping key generated by the + # '_get_generation_dict_from_tax_line' method. + # Since everything is indexed using the grouping key, we are now able to decide if + # (1) we can reuse an existing tax line and update its amounts + # (2) some tax lines are no longer used and can be dropped + # (3) we need to create new tax lines + # ========================================================================================= + + # Track the existing tax lines using the grouping key. + existing_tax_line_map = {} + for line_vals in tax_lines or []: + grouping_key = frozendict(self._get_generation_dict_from_tax_line(line_vals)) + + # After a modification (e.g. changing the analytic account of the tax line), if two tax lines are sharing + # the same key, keep only one. + if grouping_key in existing_tax_line_map: + res['tax_lines_to_delete'].append(line_vals) + else: + existing_tax_line_map[grouping_key] = line_vals + + # Update/create the tax lines. + for grouping_key, tax_values in base_line_map.items(): + if tax_values['currency_id']: + currency = self.env['res.currency'].browse(tax_values['currency_id']) + res['totals'][currency]['amount_tax'] += currency.round(tax_values['tax_amount']) + + if grouping_key in existing_tax_line_map: + # Update an existing tax line. + line_vals = existing_tax_line_map.pop(grouping_key) + res['tax_lines_to_update'].append((line_vals, tax_values)) + else: + # Create a new tax line. + res['tax_lines_to_add'].append(tax_values) + + for line_vals in existing_tax_line_map.values(): + res['tax_lines_to_delete'].append(line_vals) + + return res + + @api.model + def _aggregate_taxes_by_tax_group(self, vals_list): + """ Aggregate the values passed as parameter representing the tax amounts for each tax. + + :param vals_list: A list of dictionary containing: + * record: The originator record. + * amount: The tax amount or the base amount depending if 'tax' is specified or not. + * taxes: The applied taxes for base lines, optional for tax lines (to be used if they affect the base of + another tax). + * tax: The applied tax if 'record' is a tax line. + :return: A list of dictionaries containing: + * tax_group: An account.tax.group. + * tax_amount: The total tax amount for this group. + * base_amount: The total base amount for this group. + """ + + tax_group_mapping = defaultdict(lambda: { + 'base_lines': set(), + 'base_amount': 0.0, + 'tax_amount': 0.0, + }) + + for vals in vals_list: + record = vals.get('record') + amount = vals['amount'] + taxes = vals.get('taxes') + originator_tax = vals.get('tax') + + # Compute tax amounts. + + if originator_tax: + tax_group_vals = tax_group_mapping[originator_tax.tax_group_id] + tax_group_vals['tax_amount'] += amount + + # Compute base amounts. + + if not taxes: + continue + + for tax in taxes.flatten_taxes_hierarchy(): + + if originator_tax and originator_tax.tax_group_id == tax.tax_group_id: + continue + + tax_group_vals = tax_group_mapping[tax.tax_group_id] + if not record or record not in tax_group_vals['base_lines']: + tax_group_vals['base_amount'] += amount + tax_group_vals['base_lines'].add(record) + + tax_groups = sorted(tax_group_mapping.keys(), key=lambda x: x.sequence) + tax_group_vals_list = [] + for tax_group in tax_groups: + tax_group_vals = tax_group_mapping[tax_group] + + tax_group_vals_list.append({ + 'tax_group': tax_group, + 'tax_amount': tax_group_vals['tax_amount'], + 'base_amount': tax_group_vals['base_amount'], + }) + return tax_group_vals_list + + def _prepare_tax_totals_json(self, base_lines, currency, tax_lines=None): + """ Compute the tax totals details for the business documents. + :param base_lines: A list of python dictionaries created using the '_convert_to_tax_base_line_dict' method. + :param currency: The currency set on the business document. + :param tax_lines: Optional list of python dictionaries created using the '_convert_to_tax_line_dict' method. + If specified, the taxes will be recomputed using them instead of recomputing the taxes on + the provided base lines. + :return: A dictionary in the following form: + { + 'amount_total': The total amount to be displayed on the document, including every total + types. + 'amount_untaxed': The untaxed amount to be displayed on the document. + 'formatted_amount_total': Same as amount_total, but as a string formatted accordingly with + partner's locale. + 'formatted_amount_untaxed': Same as amount_untaxed, but as a string formatted accordingly with + partner's locale. + 'allow_tax_edition': True if the user should have the ability to manually edit the tax amounts + by group to fix rounding errors. + 'groups_by_subtotals': A dictionary formed liked {'subtotal': groups_data} + Where total_type is a subtotal name defined on a tax group, or the + default one: 'Untaxed Amount'. + And groups_data is a list of dict in the following form: + { + 'tax_group_name': The name of the tax groups this total is made for. + 'tax_group_amount': The total tax amount in this tax group. + 'tax_group_base_amount': The base amount for this tax group. + 'formatted_tax_group_amount': Same as tax_group_amount, but as a string formatted accordingly + with partner's locale. + 'formatted_tax_group_base_amount': Same as tax_group_base_amount, but as a string formatted + accordingly with partner's locale. + 'tax_group_id': The id of the tax group corresponding to this dict. + } + 'subtotals': A list of dictionaries in the following form, one for each subtotal in + 'groups_by_subtotals' keys. + { + 'name': The name of the subtotal + 'amount': The total amount for this subtotal, summing all the tax groups + belonging to preceding subtotals and the base amount + 'formatted_amount': Same as amount, but as a string formatted accordingly with + partner's locale. + } + } + """ + vals_list = [] + + recompute_taxes = tax_lines is None + + amount_untaxed = 0.0 + amount_tax = 0.0 + + # ==== Recompute taxes on-the-fly for business models that are not creating any tax lines ==== + + if recompute_taxes: + tax_results = self._compute_taxes( + base_lines, + tax_lines=tax_lines, + ) + + for base_line_vals, to_update in tax_results['base_lines_to_update']: + base_line_vals['price_subtotal'] = to_update['price_subtotal'] + + for tax_line_vals in tax_results['tax_lines_to_add']: + tax_ids = tax_line_vals['tax_ids'][0][2] if tax_line_vals['tax_ids'] else [] + tax_repartition_line = self.env['account.tax.repartition.line'].browse(tax_line_vals['tax_repartition_line_id']) + + vals_list.append({ + 'amount': tax_line_vals['tax_amount'], + 'tax': tax_repartition_line.tax_id, + 'taxes': self.env['account.tax'].browse(tax_ids), + }) + + # ==== Aggregate the tax values by tax group ==== + + for line_vals in base_lines: + amount_untaxed += line_vals['price_subtotal'] + + taxes = line_vals['taxes'] + if not taxes: + continue + + vals_list.append({ + 'record': line_vals['record'], + 'amount': line_vals['price_subtotal'], + 'taxes': taxes, + }) + + if not recompute_taxes: + for line_vals in tax_lines: + tax_repartition_line = line_vals['tax_repartition_line'] + taxes = line_vals['taxes'] + + vals_list.append({ + 'record': line_vals['record'], + 'amount': line_vals['tax_amount'], + 'taxes': taxes, + 'tax': tax_repartition_line.tax_id, + }) + + tax_group_vals_list = self._aggregate_taxes_by_tax_group(vals_list) + + # ==== Partition the tax group values by subtotals ==== + + subtotal_order = {} + groups_by_subtotal = {} + for tax_group_vals in tax_group_vals_list: + tax_group = tax_group_vals['tax_group'] + + if tax_group.preceding_subtotal: + subtotal_title = tax_group.preceding_subtotal + sequence = tax_group.sequence + 1 # Avoid sequence = 0 here. + else: + subtotal_title = _("Untaxed Amount") + sequence = 0 + + if subtotal_title not in subtotal_order: + subtotal_order[subtotal_title] = sequence + groups_by_subtotal[subtotal_title] = [] + + groups_by_subtotal[subtotal_title].append({ + 'group_key': tax_group.id, + 'tax_group_id': tax_group.id, + 'tax_group_name': tax_group.name, + 'tax_group_amount': tax_group_vals['tax_amount'], + 'tax_group_base_amount': tax_group_vals['base_amount'], + 'formatted_tax_group_amount': formatLang(self.env, tax_group_vals['tax_amount'], currency_obj=currency), + 'formatted_tax_group_base_amount': formatLang(self.env, tax_group_vals['base_amount'], currency_obj=currency), + }) + + # ==== Build the final result ==== + + subtotals = [] + for subtotal_title in sorted(subtotal_order.keys(), key=lambda k: subtotal_order[k]): + amount_total = amount_untaxed + amount_tax + subtotals.append({ + 'name': subtotal_title, + 'amount': amount_total, + 'formatted_amount': formatLang(self.env, amount_total, currency_obj=currency), + }) + amount_tax += sum(x['tax_group_amount'] for x in groups_by_subtotal[subtotal_title]) + + amount_total = amount_untaxed + amount_tax + + return { + 'amount_untaxed': amount_untaxed, + 'amount_total': amount_total, + 'formatted_amount_total': formatLang(self.env, amount_total, currency_obj=currency), + 'formatted_amount_untaxed': formatLang(self.env, amount_untaxed, currency_obj=currency), + 'groups_by_subtotal': groups_by_subtotal, + 'subtotals': subtotals, + 'allow_tax_edition': False, + } + @api.model def _fix_tax_included_price(self, price, prod_taxes, line_taxes): """Subtract tax amount from price when corresponding "price included" taxes do not apply""" @@ -766,3 +1201,14 @@ class AccountTaxRepartitionLine(models.Model): def _onchange_repartition_type(self): if self.repartition_type == 'base': self.account_id = None + + def _get_aml_target_tax_account(self): + """ Get the default tax account to set on a business line. + + :return: An account.account record or an empty recordset. + """ + self.ensure_one() + if self.tax_id.tax_exigibility == 'on_payment': + return self.tax_id.cash_basis_transition_account_id + else: + return self.account_id diff --git a/addons/account/tests/test_account_move_entry.py b/addons/account/tests/test_account_move_entry.py index 8c20b669a6a..c414133b201 100644 --- a/addons/account/tests/test_account_move_entry.py +++ b/addons/account/tests/test_account_move_entry.py @@ -486,7 +486,7 @@ class TestAccountMove(AccountTestInvoicingCommon): move = move_form.save() - self.assertRecordValues(move.line_ids, [ + self.assertRecordValues(move.line_ids.sorted(lambda x: -x.balance), [ {'name': 'debit_line_1', 'debit': 1000.0, 'credit': 0.0, 'tax_ids': [self.included_percent_tax.id], 'tax_line_id': False}, {'name': 'included_tax_line', 'debit': 200.0, 'credit': 0.0, 'tax_ids': [], 'tax_line_id': self.included_percent_tax.id}, {'name': 'credit_line_1', 'debit': 0.0, 'credit': 1200.0, 'tax_ids': [], 'tax_line_id': False}, diff --git a/addons/account/tests/test_account_move_in_invoice.py b/addons/account/tests/test_account_move_in_invoice.py index f2a1629933c..8b192be682f 100644 --- a/addons/account/tests/test_account_move_in_invoice.py +++ b/addons/account/tests/test_account_move_in_invoice.py @@ -527,7 +527,7 @@ class TestAccountMoveInInvoiceOnchanges(AccountTestInvoicingCommon): ], self.move_vals) move_form = Form(self.invoice) - with move_form.line_ids.edit(2) as line_form: + with move_form.invoice_line_ids.edit(0) as line_form: # Reset field except the discount that becomes 100%. # /!\ The modification is made on the accounting tab. line_form.quantity = 1 diff --git a/addons/account/tests/test_account_move_in_refund.py b/addons/account/tests/test_account_move_in_refund.py index c2a27ef1383..1d9c44b1cde 100644 --- a/addons/account/tests/test_account_move_in_refund.py +++ b/addons/account/tests/test_account_move_in_refund.py @@ -211,7 +211,7 @@ class TestAccountMoveInRefundOnchanges(AccountTestInvoicingCommon): ], self.move_vals) move_form = Form(self.invoice) - with move_form.line_ids.edit(2) as line_form: + with move_form.invoice_line_ids.edit(0) as line_form: # Reset field except the discount that becomes 100%. # /!\ The modification is made on the accounting tab. line_form.quantity = 1 diff --git a/addons/account/tests/test_account_move_out_invoice.py b/addons/account/tests/test_account_move_out_invoice.py index f8d797a91bd..5623379c812 100644 --- a/addons/account/tests/test_account_move_out_invoice.py +++ b/addons/account/tests/test_account_move_out_invoice.py @@ -517,7 +517,7 @@ class TestAccountMoveOutInvoiceOnchanges(AccountTestInvoicingCommon): ], self.move_vals) move_form = Form(self.invoice) - with move_form.line_ids.edit(2) as line_form: + with move_form.invoice_line_ids.edit(0) as line_form: # Reset field except the discount that becomes 100%. # /!\ The modification is made on the accounting tab. line_form.quantity = 1 diff --git a/addons/account/tests/test_account_move_out_refund.py b/addons/account/tests/test_account_move_out_refund.py index ab5325d9c45..bf78699c09c 100644 --- a/addons/account/tests/test_account_move_out_refund.py +++ b/addons/account/tests/test_account_move_out_refund.py @@ -210,7 +210,7 @@ class TestAccountMoveOutRefundOnchanges(AccountTestInvoicingCommon): ], self.move_vals) move_form = Form(self.invoice) - with move_form.line_ids.edit(2) as line_form: + with move_form.invoice_line_ids.edit(0) as line_form: # Reset field except the discount that becomes 100%. # /!\ The modification is made on the accounting tab. line_form.quantity = 1 diff --git a/addons/l10n_ar/models/account_move.py b/addons/l10n_ar/models/account_move.py index 1a97922ee30..14075def91a 100644 --- a/addons/l10n_ar/models/account_move.py +++ b/addons/l10n_ar/models/account_move.py @@ -324,22 +324,32 @@ class AccountMove(models.Model): def _l10n_ar_get_invoice_totals_for_report(self): self.ensure_one() - tax_ids_filter = tax_line_id_filter = None include_vat = self._l10n_ar_include_vat() + base_lines = self.line_ids.filtered(lambda x: not x.display_type and not x.exclude_from_invoice_tab) + tax_lines = self.line_ids.filtered(lambda x: not x.display_type and x.tax_repartition_line_id) + # Base lines. + base_line_vals_list = [x._convert_to_tax_base_line_dict() for x in base_lines] if include_vat: - tax_ids_filter = (lambda aml, tax: not bool(tax.tax_group_id.l10n_ar_vat_afip_code)) - tax_line_id_filter = (lambda aml, tax: not bool(tax.tax_group_id.l10n_ar_vat_afip_code)) - - tax_lines_data = self._prepare_tax_lines_data_for_totals_from_invoice( - tax_ids_filter=tax_ids_filter, tax_line_id_filter=tax_line_id_filter) + for vals in base_line_vals_list: + vals['taxes'] = vals['taxes']\ + .flatten_taxes_hierarchy()\ + .filtered(lambda tax: not tax.tax_group_id.l10n_ar_vat_afip_code) + # Tax lines. + tax_line_vals_list = [x._convert_to_tax_line_dict() for x in tax_lines] if include_vat: - amount_untaxed = self.currency_id.round( - self.amount_total - sum([x['tax_amount'] for x in tax_lines_data if 'tax_amount' in x])) - else: - amount_untaxed = self.amount_untaxed - return self._get_tax_totals(self.partner_id, tax_lines_data, self.amount_total, amount_untaxed, self.currency_id) + tax_line_vals_list = [ + x + for x in tax_line_vals_list + if not x['tax_repartition_line'].tax_id.tax_group_id.l10n_ar_vat_afip_code + ] + + return self.env['account.tax']._prepare_tax_totals_json( + base_line_vals_list, + self.currency_id, + tax_lines=tax_line_vals_list, + ) def _l10n_ar_include_vat(self): self.ensure_one() diff --git a/addons/l10n_in/models/account.py b/addons/l10n_in/models/account.py index 8533804ee00..f9891b456fe 100644 --- a/addons/l10n_in/models/account.py +++ b/addons/l10n_in/models/account.py @@ -50,10 +50,28 @@ class AccountTax(models.Model): l10n_in_reverse_charge = fields.Boolean("Reverse charge", help="Tick this if this tax is reverse charge. Only for Indian accounting") - def get_grouping_key(self, invoice_tax_val): - """ Returns a string that will be used to group account.invoice.tax sharing the same properties""" - key = super(AccountTax, self).get_grouping_key(invoice_tax_val) - if self.company_id.account_fiscal_country_id.code == 'IN': - key += "-%s-%s"% (invoice_tax_val.get('l10n_in_product_id', False), - invoice_tax_val.get('l10n_in_uom_id', False)) - return key + @api.model + def _get_generation_dict_from_base_line(self, line_vals, tax_vals): + # EXTENDS account + # Group taxes also by product. + res = super()._get_generation_dict_from_base_line(line_vals, tax_vals) + record = line_vals['record'] + if isinstance(record, models.Model)\ + and record._name == 'account.move.line'\ + and record.company_id.account_fiscal_country_id.code == 'IN': + res['product_id'] = record.product_id.id + res['product_uom_id'] = record.product_uom_id.id + return res + + @api.model + def _get_generation_dict_from_tax_line(self, line_vals): + # EXTENDS account + # Group taxes also by product. + res = super()._get_generation_dict_from_tax_line(line_vals) + record = line_vals['record'] + if isinstance(record, models.Model)\ + and record._name == 'account.move.line'\ + and record.company_id.account_fiscal_country_id.code == 'IN': + res['product_id'] = record.product_id.id + res['product_uom_id'] = record.product_uom_id.id + return res diff --git a/addons/l10n_in/models/account_invoice.py b/addons/l10n_in/models/account_invoice.py index 0f01552ffa7..1260d5b740d 100644 --- a/addons/l10n_in/models/account_invoice.py +++ b/addons/l10n_in/models/account_invoice.py @@ -48,36 +48,6 @@ class AccountMove(models.Model): return self.env.ref('l10n_in.state_in_ot') return partner.state_id - - @api.model - def _get_tax_grouping_key_from_tax_line(self, tax_line): - # OVERRIDE to group taxes also by product. - res = super()._get_tax_grouping_key_from_tax_line(tax_line) - if tax_line.move_id.journal_id.company_id.account_fiscal_country_id.code == 'IN': - res['product_id'] = tax_line.product_id.id - res['product_uom_id'] = tax_line.product_uom_id.id - return res - - @api.model - def _get_tax_grouping_key_from_base_line(self, base_line, tax_vals): - # OVERRIDE to group taxes also by product. - res = super()._get_tax_grouping_key_from_base_line(base_line, tax_vals) - if base_line.move_id.journal_id.company_id.account_fiscal_country_id.code == 'IN': - res['product_id'] = base_line.product_id.id - res['product_uom_id'] = base_line.product_uom_id.id - return res - - @api.model - def _get_tax_key_for_group_add_base(self, line): - # DEPRECATED: TO BE REMOVED IN MASTER - tax_key = super(AccountMove, self)._get_tax_key_for_group_add_base(line) - - tax_key += [ - line.product_id.id, - line.product_uom_id.id, - ] - return tax_key - def _l10n_in_get_shipping_partner(self): """Overwrite in sale""" self.ensure_one() diff --git a/addons/purchase/models/purchase.py b/addons/purchase/models/purchase.py index 945d48a5172..c90ebc065f8 100644 --- a/addons/purchase/models/purchase.py +++ b/addons/purchase/models/purchase.py @@ -24,17 +24,10 @@ class PurchaseOrder(models.Model): @api.depends('order_line.price_total') def _amount_all(self): for order in self: - amount_untaxed = amount_tax = 0.0 - for line in order.order_line: - line._compute_amount() - amount_untaxed += line.price_subtotal - amount_tax += line.price_tax - currency = order.currency_id or order.partner_id.property_purchase_currency_id or self.env.company.currency_id - order.update({ - 'amount_untaxed': currency.round(amount_untaxed), - 'amount_tax': currency.round(amount_tax), - 'amount_total': amount_untaxed + amount_tax, - }) + order_lines = order.order_line.filtered(lambda x: not x.display_type) + order.amount_untaxed = sum(order_lines.mapped('price_subtotal')) + order.amount_total = sum(order_lines.mapped('price_total')) + order.amount_tax = sum(order_lines.mapped('price_tax')) @api.depends('state', 'order_line.qty_to_invoice') def _get_invoiced(self): @@ -205,14 +198,12 @@ class PurchaseOrder(models.Model): @api.depends('order_line.taxes_id', 'order_line.price_subtotal', 'amount_total', 'amount_untaxed') def _compute_tax_totals_json(self): - def compute_taxes(order_line): - return order_line.taxes_id._origin.compute_all(**order_line._prepare_compute_all_values()) - - account_move = self.env['account.move'] for order in self: - tax_lines_data = account_move._prepare_tax_lines_data_for_totals_from_object(order.order_line, compute_taxes) - tax_totals = account_move._get_tax_totals(order.partner_id, tax_lines_data, order.amount_total, order.amount_untaxed, order.currency_id) - order.tax_totals_json = json.dumps(tax_totals) + order_lines = order.order_line.filtered(lambda x: not x.display_type) + order.tax_totals_json = json.dumps(self.env['account.tax']._prepare_tax_totals_json( + [x._convert_to_tax_base_line_dict() for x in order_lines], + order.currency_id, + )) @api.depends('company_id.account_fiscal_country_id', 'fiscal_position_id.country_id', 'fiscal_position_id.foreign_vat') def _compute_tax_country_id(self): @@ -978,27 +969,34 @@ class PurchaseOrderLine(models.Model): @api.depends('product_qty', 'price_unit', 'taxes_id') def _compute_amount(self): for line in self: - taxes = line.taxes_id.compute_all(**line._prepare_compute_all_values()) + tax_results = self.env['account.tax']._compute_taxes([line._convert_to_tax_base_line_dict()]) + totals = list(tax_results['totals'].values())[0] + amount_untaxed = totals['amount_untaxed'] + amount_tax = totals['amount_tax'] + line.update({ - 'price_tax': taxes['total_included'] - taxes['total_excluded'], - 'price_total': taxes['total_included'], - 'price_subtotal': taxes['total_excluded'], + 'price_subtotal': amount_untaxed, + 'price_tax': amount_tax, + 'price_total': amount_untaxed + amount_tax, }) - def _prepare_compute_all_values(self): - # Hook method to returns the different argument values for the - # compute_all method, due to the fact that discounts mechanism - # is not implemented yet on the purchase orders. - # This method should disappear as soon as this feature is - # also introduced like in the sales module. + def _convert_to_tax_base_line_dict(self): + """ Convert the current record to a dictionary in order to use the generic taxes computation method + defined on account.tax. + + :return: A python dictionary. + """ self.ensure_one() - return { - 'price_unit': self.price_unit, - 'currency': self.order_id.currency_id, - 'quantity': self.product_qty, - 'product': self.product_id, - 'partner': self.order_id.partner_id, - } + return self.env['account.tax']._convert_to_tax_base_line_dict( + self, + partner=self.order_id.partner_id, + currency=self.order_id.currency_id, + product=self.product_id, + taxes=self.taxes_id, + price_unit=self.price_unit, + quantity=self.product_qty, + price_subtotal=self.price_subtotal, + ) def _compute_tax_id(self): for line in self: diff --git a/addons/sale/models/sale_order.py b/addons/sale/models/sale_order.py index b9b3e5f8991..f65ac552d78 100644 --- a/addons/sale/models/sale_order.py +++ b/addons/sale/models/sale_order.py @@ -404,21 +404,16 @@ class SaleOrder(models.Model): user_id=user_id, domain=[('company_id', 'in', [company_id, False])]) order.team_id = cached_teams[key] - @api.depends('order_line.price_total') + @api.depends('order_line.price_subtotal', 'order_line.price_tax', 'order_line.price_total') def _amount_all(self): """ Compute the total amounts of the SO. """ for order in self: - amount_untaxed = amount_tax = 0.0 - for line in order.order_line: - amount_untaxed += line.price_subtotal - amount_tax += line.price_tax - order.update({ - 'amount_untaxed': amount_untaxed, - 'amount_tax': amount_tax, - 'amount_total': amount_untaxed + amount_tax, - }) + order_lines = order.order_line.filtered(lambda x: not x.display_type) + order.amount_untaxed = sum(order_lines.mapped('price_subtotal')) + order.amount_total = sum(order_lines.mapped('price_total')) + order.amount_tax = sum(order_lines.mapped('price_tax')) @api.depends('order_line.invoice_lines') def _get_invoiced(self): @@ -546,16 +541,12 @@ class SaleOrder(models.Model): @api.depends('order_line.tax_id', 'order_line.price_unit', 'amount_total', 'amount_untaxed') def _compute_tax_totals_json(self): - def compute_taxes(order_line): - price = order_line.price_unit * (1 - (order_line.discount or 0.0) / 100.0) - order = order_line.order_id - return order_line.tax_id._origin.compute_all(price, order.currency_id, order_line.product_uom_qty, product=order_line.product_id, partner=order.partner_shipping_id) - - account_move = self.env['account.move'] for order in self: - tax_lines_data = account_move._prepare_tax_lines_data_for_totals_from_object(order.order_line, compute_taxes) - tax_totals = account_move._get_tax_totals(order.partner_id, tax_lines_data, order.amount_total, order.amount_untaxed, order.currency_id) - order.tax_totals_json = json.dumps(tax_totals) + order_lines = order.order_line.filtered(lambda x: not x.display_type) + order.tax_totals_json = json.dumps(self.env['account.tax']._prepare_tax_totals_json( + [x._convert_to_tax_base_line_dict() for x in order_lines], + order.currency_id, + )) @api.depends('state') def _compute_type_name(self): diff --git a/addons/sale/models/sale_order_line.py b/addons/sale/models/sale_order_line.py index edc94dbcf9d..3eee63b6093 100644 --- a/addons/sale/models/sale_order_line.py +++ b/addons/sale/models/sale_order_line.py @@ -544,25 +544,40 @@ class SaleOrderLine(models.Model): for line in self: line.price_reduce = line.price_unit * (1.0 - line.discount / 100.0) + def _convert_to_tax_base_line_dict(self): + """ Convert the current record to a dictionary in order to use the generic taxes computation method + defined on account.tax. + + :return: A python dictionary. + """ + self.ensure_one() + return self.env['account.tax']._convert_to_tax_base_line_dict( + self, + partner=self.order_id.partner_id, + currency=self.order_id.currency_id, + product=self.product_id, + taxes=self.tax_id, + price_unit=self.price_unit, + quantity=self.product_uom_qty, + discount=self.discount, + price_subtotal=self.price_subtotal, + ) + @api.depends('product_uom_qty', 'discount', 'price_unit', 'tax_id') def _compute_amount(self): """ Compute the amounts of the SO line. """ for line in self: - if line.display_type: - line.update({ - 'price_tax': 0.0, - 'price_total': 0.0, - 'price_subtotal': 0.0, - }) - continue - price = line.price_unit * (1 - (line.discount or 0.0) / 100.0) - taxes = line.tax_id.compute_all(price, line.currency_id, line.product_uom_qty, product=line.product_id, partner=line.order_id.partner_shipping_id) + tax_results = self.env['account.tax']._compute_taxes([line._convert_to_tax_base_line_dict()]) + totals = list(tax_results['totals'].values())[0] + amount_untaxed = totals['amount_untaxed'] + amount_tax = totals['amount_tax'] + line.update({ - 'price_tax': taxes['total_included'] - taxes['total_excluded'], - 'price_total': taxes['total_included'], - 'price_subtotal': taxes['total_excluded'], + 'price_subtotal': amount_untaxed, + 'price_tax': amount_tax, + 'price_total': amount_untaxed + amount_tax, }) if self.env.context.get('import_file', False) and not self.env.user.user_has_groups('account.group_account_manager'): line.tax_id.invalidate_cache(['invoice_repartition_line_ids'], [line.tax_id.id])