diff --git a/addons/account/models/account_account_tag.py b/addons/account/models/account_account_tag.py index f91a429a6e8..040d67fddbc 100644 --- a/addons/account/models/account_account_tag.py +++ b/addons/account/models/account_account_tag.py @@ -21,7 +21,7 @@ class AccountAccountTag(models.Model): in the specified country. """ domain = self._get_tax_tags_domain(tag_name, country_id) - return self.env['account.account.tag'].search(domain) + return self.env['account.account.tag'].with_context(active_test=False).search(domain) @api.model def _get_tax_tags_domain(self, tag_name, country_id, sign=None): diff --git a/addons/account/models/account_move_line.py b/addons/account/models/account_move_line.py index 5537a2e41e6..7f06b750997 100644 --- a/addons/account/models/account_move_line.py +++ b/addons/account/models/account_move_line.py @@ -203,6 +203,7 @@ class AccountMoveLine(models.Model): string="Tags", comodel_name='account.account.tag', ondelete='restrict', + context={'active_test': False}, tracking=True, help="Tags assigned to this line by the tax creating it, if any. It determines its impact on financial reports.", ) diff --git a/addons/account/models/account_report.py b/addons/account/models/account_report.py index 47606a38bcd..c5f37b6a298 100644 --- a/addons/account/models/account_report.py +++ b/addons/account/models/account_report.py @@ -4,7 +4,7 @@ import re from collections import defaultdict -from odoo import models, fields, api, _, osv +from odoo import models, fields, api, _, osv, Command from odoo.exceptions import ValidationError, UserError FIGURE_TYPE_SELECTION_VALUES = [ @@ -388,6 +388,16 @@ class AccountReportLine(models.Model): if vals_list: self.env['account.report.expression'].create(vals_list) + @api.ondelete(at_uninstall=False) + def _unlink_child_expressions(self): + """ + We explicitly unlink child expressions. + This is necessary even if there is an ondelete='cascade' on it, because + the @api.ondelete method _unlink_archive_used_tags is not automatically + called if the parent model is deleted. + """ + self.expression_ids.unlink() + class AccountReportExpression(models.Model): _name = "account.report.expression" @@ -472,8 +482,9 @@ class AccountReportExpression(models.Model): country = expression.report_line_id.report_id.country_id existing_tags = self.env['account.account.tag']._get_tax_tags(tag_name, country.id) - if not existing_tags: - tag_vals = self._get_tags_create_vals(tag_name, country.id) + if len(existing_tags) < 2: + # We can have only one tag in case we archived it and deleted its unused complement sign + tag_vals = self._get_tags_create_vals(tag_name, country.id, existing_tag=existing_tags) self.env['account.account.tag'].create(tag_vals) return result @@ -510,6 +521,35 @@ class AccountReportExpression(models.Model): return result + @api.ondelete(at_uninstall=False) + def _unlink_archive_used_tags(self): + """ + Manages unlink or archive of tax_tags when account.report.expression are deleted. + If a tag is still in use on amls, we archive it. + """ + expressions_tags = self._get_matching_tags() + tags_to_archive = self.env['account.account.tag'] + tags_to_unlink = self.env['account.account.tag'] + for tag in expressions_tags: + other_expression_using_tag = self.env['account.report.expression'].sudo().search([ + ('engine', '=', 'tax_tags'), + ('formula', '=', tag.name[1:]), # we escape the +/- sign + ('report_line_id.report_id.country_id.id', '=', tag.country_id.id), + ('id', 'not in', self.ids), + ], limit=1) + if not other_expression_using_tag: + aml_using_tag = self.env['account.move.line'].sudo().search([('tax_tag_ids', 'in', tag.id)], limit=1) + if aml_using_tag: + tags_to_archive += tag + else: + tags_to_unlink += tag + + if tags_to_archive or tags_to_unlink: + rep_lines_with_tag = self.env['account.tax.repartition.line'].sudo().search([('tag_ids', 'in', (tags_to_archive + tags_to_unlink).ids)]) + rep_lines_with_tag.write({'tag_ids': [Command.unlink(tag.id) for tag in tags_to_archive + tags_to_unlink]}) + tags_to_archive.active = False + tags_to_unlink.unlink() + def name_get(self): return [(expr.id, f'{expr.report_line_name} [{expr.label}]') for expr in self] @@ -570,10 +610,15 @@ class AccountReportExpression(models.Model): country = tag_expression.report_line_id.report_id.country_id or_domains.append(self.env['account.account.tag']._get_tax_tags_domain(tag_expression.formula, country.id)) - return self.env['account.account.tag'].search(osv.expression.OR(or_domains)) + return self.env['account.account.tag'].with_context(active_test=False).search(osv.expression.OR(or_domains)) @api.model - def _get_tags_create_vals(self, tag_name, country_id): + def _get_tags_create_vals(self, tag_name, country_id, existing_tag=None): + """ + We create the plus and minus tags with tag_name. + In case there is an existing_tag (which can happen if we deleted its unused complement sign) + we only recreate the missing sign. + """ minus_tag_vals = { 'name': '-' + tag_name, 'applicability': 'taxes', @@ -586,7 +631,12 @@ class AccountReportExpression(models.Model): 'tax_negate': False, 'country_id': country_id, } - return [(minus_tag_vals), (plus_tag_vals)] + res = [] + if not existing_tag or not existing_tag.tax_negate: + res.append(minus_tag_vals) + if not existing_tag or existing_tag.tax_negate: + res.append(plus_tag_vals) + return res def _get_carryover_target_expression(self, options): self.ensure_one() diff --git a/addons/account/models/chart_template.py b/addons/account/models/chart_template.py index 7041865cbd4..c2628e0c47b 100644 --- a/addons/account/models/chart_template.py +++ b/addons/account/models/chart_template.py @@ -1485,7 +1485,7 @@ class AccountTaxRepartitionLineTemplate(models.Model): domains.append(self.env['account.account.tag']._get_tax_tags_domain(report_expression.formula, country.id, sign=sign)) if domains: - tags_to_add |= self.env['account.account.tag'].search(osv.expression.OR(domains)) + tags_to_add |= self.env['account.account.tag'].with_context(active_test=False).search(osv.expression.OR(domains)) return tags_to_add diff --git a/addons/account/tests/test_tax_report.py b/addons/account/tests/test_tax_report.py index 3d6482c7fb2..a22ee09b7f7 100644 --- a/addons/account/tests/test_tax_report.py +++ b/addons/account/tests/test_tax_report.py @@ -68,11 +68,11 @@ class TaxReportTest(AccountTestInvoicingCommon): ], }) - def _get_tax_tags(self, country, tag_name=None): + def _get_tax_tags(self, country, tag_name=None, active_test=True): domain = [('country_id', '=', country.id), ('applicability', '=', 'taxes')] if tag_name: - domain.append(('name', 'like', '_' + tag_name )) - return self.env['account.account.tag'].search(domain) + domain.append(('name', 'like', '_' + tag_name)) + return self.env['account.account.tag'].with_context(active_test=active_test).search(domain) def test_create_shared_tags(self): self.assertEqual(len(self._get_tax_tags(self.test_country_1, tag_name='01')), 2, "tax_tags expressions created for reports within the same countries using the same formula should create a single pair of tags.") @@ -178,3 +178,78 @@ class TaxReportTest(AccountTestInvoicingCommon): # Tags already exist since 'copied_report_1' belongs to 'test_country_2' for tag in line_tags: self.assertIn(tag, country_2_tags_after_change, "The tax_tags expressions sharing their tags with other report should not receive new tags since they already exist.") + + def test_unlink_report_line_tags_used_by_amls(self): + """ + Deletion of a report line whose tags are still referenced by an aml should archive tags and not delete them. + """ + tag_name = "55b" + tax_report_line = self._create_basic_tax_report_line(self.tax_report_1, "Line 55 bis", tag_name) + test_tag = tax_report_line.expression_ids._get_matching_tags().filtered(lambda tag: not tag.tax_negate) + test_tax = self.env['account.tax'].create({ + 'name': "Test tax", + 'amount_type': 'percent', + 'amount': 25, + 'country_id': self.tax_report_1.country_id.id, + 'type_tax_use': 'sale', + 'invoice_repartition_line_ids': [ + (0, 0, {'factor_percent': 100, 'repartition_type': 'base'}), + (0, 0, { + 'factor_percent': 100, + 'repartition_type': 'tax', + 'tag_ids': [Command.link(test_tag.id)], + }), + ], + 'refund_repartition_line_ids': [ + (0, 0, {'factor_percent': 100, 'repartition_type': 'base'}), + (0, 0, {'factor_percent': 100, 'repartition_type': 'tax'}), + ], + }) + + # Make sure the fiscal country allows using this tax directly + self.env.company.account_fiscal_country_id = self.test_country_1 + + test_invoice = self.env['account.move'].create({ + 'move_type': 'out_invoice', + 'partner_id': self.partner_a.id, + 'date': '1992-12-22', + 'invoice_line_ids': [ + (0, 0, {'quantity': 1, 'price_unit': 42, 'tax_ids': [Command.set([test_tax.id])]}), + ], + }) + test_invoice.action_post() + + tax_report_line.unlink() + tags_after = self._get_tax_tags(self.test_country_1, tag_name=tag_name, active_test=False) + # only the +tag_name should be kept (and archived), -tag_name should be unlinked + self.assertEqual(tags_after.mapped('tax_negate'), [False], "Unlinking a tax_tags expression should keep the tag if it was used on move lines, and unlink it otherwise.") + self.assertEqual(tags_after.mapped('active'), [False], "Unlinking a tax_tags expression should archive the tag if it was used on move lines, and unlink it otherwise.") + self.assertEqual(len(test_tax.invoice_repartition_line_ids.tag_ids), 0, "After a tag is archived it shouldn't be on tax repartition lines.") + + def test_unlink_report_line_tags_used_by_other_expression(self): + """ + Deletion of a report line whose tags are still referenced in other expression should not delete nor archive tags. + """ + tag_name = self.tax_report_line_1_1.expression_ids.formula # tag "O1" is used in both line 1.1 and line 2.1 + tags_before = self._get_tax_tags(self.test_country_1, tag_name=tag_name, active_test=False) + tags_archived_before = tags_before.filtered(lambda tag: not tag.active) + self.tax_report_line_1_1.unlink() + tags_after = self._get_tax_tags(self.test_country_1, tag_name=tag_name, active_test=False) + tags_archived_after = tags_after.filtered(lambda tag: not tag.active) + self.assertEqual(len(tags_after), len(tags_before), "Unlinking a report expression whose tags are used by another expression should not delete them.") + self.assertEqual(len(tags_archived_after), len(tags_archived_before), "Unlinking a report expression whose tags are used by another expression should not archive them.") + + def test_tag_recreation_archived(self): + """ + In a situation where we have only one of the two (+ and -) sign that exist + we want only the missing sign to be re-created if we try to reuse the same tag name. + (We can get into this state when only one of the signs were used by aml: then we archived it and deleted the complement.) + """ + tag_name = self.tax_report_line_1_55.expression_ids.formula + tags_before = self._get_tax_tags(self.test_country_1, tag_name=tag_name, active_test=False) + tags_before[0].unlink() # we unlink one and archive the other, doesn't matter which one + tags_before[1].active = False + self._create_basic_tax_report_line(self.tax_report_1, "Line 55 bis", tag_name) + tags_after = self._get_tax_tags(self.test_country_1, tag_name=tag_name, active_test=False) + self.assertEqual(len(tags_after), 2, "When creating a tax report line with an archived tag and it's complement doesn't exist, it should be re-created.") + self.assertEqual(tags_after.mapped('name'), ['+' + tag_name, '-' + tag_name], "After creating a tax report line with an archived tag and when its complement doesn't exist, both a negative and a positive tag should be created.")