From cdcbffe1ce60829c903e66b8ea39d8d0c86c3ea8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Valyi?= Date: Wed, 7 May 2025 13:38:40 +0000 Subject: [PATCH] [IMP] l10n_br_fiscal: editable data mixin --- l10n_br_fiscal/models/__init__.py | 1 + l10n_br_fiscal/models/cfop.py | 4 + l10n_br_fiscal/models/cnae.py | 4 + l10n_br_fiscal/models/cst.py | 4 + l10n_br_fiscal/models/data_abstract.py | 17 +- l10n_br_fiscal/models/data_editable_mixin.py | 274 +++++++++++++ l10n_br_fiscal/models/nbs.py | 4 + l10n_br_fiscal/models/ncm.py | 7 + l10n_br_fiscal/models/service_type.py | 14 +- l10n_br_fiscal/models/tax.py | 66 ++++ l10n_br_fiscal/models/tax_pis_cofins.py | 4 + l10n_br_fiscal/tests/__init__.py | 1 + .../tests/test_data_editable_mixin.py | 362 ++++++++++++++++++ 13 files changed, 747 insertions(+), 15 deletions(-) create mode 100644 l10n_br_fiscal/models/data_editable_mixin.py create mode 100644 l10n_br_fiscal/tests/test_data_editable_mixin.py diff --git a/l10n_br_fiscal/models/__init__.py b/l10n_br_fiscal/models/__init__.py index e10417f83eee..a20ead174a2e 100644 --- a/l10n_br_fiscal/models/__init__.py +++ b/l10n_br_fiscal/models/__init__.py @@ -1,5 +1,6 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from . import data_editable_mixin from . import data_abstract from . import data_product_abstract from . import data_ncm_nbs_abstract diff --git a/l10n_br_fiscal/models/cfop.py b/l10n_br_fiscal/models/cfop.py index ef42c622a286..f1d4d0da4a7b 100644 --- a/l10n_br_fiscal/models/cfop.py +++ b/l10n_br_fiscal/models/cfop.py @@ -198,3 +198,7 @@ def _compute_destination(self): "CFOP already exists with this code !", ) ] + + def _get_xml_id_name(self): + self.ensure_one() + return f"cfop_{self.code}" diff --git a/l10n_br_fiscal/models/cnae.py b/l10n_br_fiscal/models/cnae.py index 0098fa4d49d5..bdcf52ded3d6 100644 --- a/l10n_br_fiscal/models/cnae.py +++ b/l10n_br_fiscal/models/cnae.py @@ -37,3 +37,7 @@ class Cnae(models.Model): _("CNAE already exists with this code !"), ) ] + + def _get_xml_id_name(self): + self.ensure_one() + return f"cnae_{self.code.replace('.', '').replace('/', '').replace('-', '')}" diff --git a/l10n_br_fiscal/models/cst.py b/l10n_br_fiscal/models/cst.py index 24e582d5d9e3..db7e5c3d5d55 100644 --- a/l10n_br_fiscal/models/cst.py +++ b/l10n_br_fiscal/models/cst.py @@ -37,3 +37,7 @@ class CST(models.Model): _("CST already exists with this code !"), ) ] + + def _get_xml_id_name(self): + self.ensure_one() + return f"cst_{self.tax_domain}_{self.code}" diff --git a/l10n_br_fiscal/models/data_abstract.py b/l10n_br_fiscal/models/data_abstract.py index fc301ae5cd4c..e4dc5fefaae9 100644 --- a/l10n_br_fiscal/models/data_abstract.py +++ b/l10n_br_fiscal/models/data_abstract.py @@ -6,14 +6,15 @@ from erpbrasil.base import misc from lxml import etree -from odoo import _, api, fields, models -from odoo.exceptions import AccessError +from odoo import api, fields, models from odoo.osv import expression class DataAbstract(models.AbstractModel): _name = "l10n_br_fiscal.data.abstract" _description = "Fiscal Data Abstract" + _inherit = "l10n_br_fiscal.data.editable.mixin" + _order = "code" code = fields.Char(required=True, index=True) @@ -24,18 +25,6 @@ class DataAbstract(models.AbstractModel): string="Unmasked Code", compute="_compute_code_unmasked", store=True, index=True ) - active = fields.Boolean(default=True) - - def action_archive(self): - if not self.env.user.has_group("l10n_br_fiscal.group_manager"): - raise AccessError(_("You don't have permission to archive records.")) - return super().action_archive() - - def action_unarchive(self): - if not self.env.user.has_group("l10n_br_fiscal.group_manager"): - raise AccessError(_("You don't have permission to unarchive records.")) - return super().action_unarchive() - @api.depends("code") def _compute_code_unmasked(self): for r in self: diff --git a/l10n_br_fiscal/models/data_editable_mixin.py b/l10n_br_fiscal/models/data_editable_mixin.py new file mode 100644 index 000000000000..c0a6b7d2efb8 --- /dev/null +++ b/l10n_br_fiscal/models/data_editable_mixin.py @@ -0,0 +1,274 @@ +# Copyright 2025-TODAY Akretion - Raphael Valyi +# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import AccessError, UserError + +_logger = logging.getLogger(__name__) + + +class EditableDataMixin(models.AbstractModel): + """ + Mixin to automatically manage ir.model.data entries for records. + + Features: + - Automatically creates ir.model.data entries for manually created records. + - Skips creation if an entry already exists (e.g., from XML/CSV import). + - Requires inheriting models to define `_xml_id_module` and implement + `_get_xml_id_name()` to specify the naming pattern. + - Provides `get_records_without_xmlid()` to find records lacking an XML ID. + - Provides `fill_missing_xml_ids()` to backfill missing XML IDs for + existing records. + - Prevent writing values inconsistent with record xml_id. + - Allow to toggle update/noupdate mode. + """ + + _name = "l10n_br_fiscal.data.editable.mixin" + _description = "Mixin for Automatic ir.model.data Management" + + # To be defined in the inheriting model + _xml_id_module = "l10n_br_fiscal" + + active = fields.Boolean(default=True) + + def _get_xml_id_name(self): + """ + Calculate the specific 'name' for the ir.model.data entry. + This method MUST be implemented by the inheriting model. + + :param self: A singleton recordset of the inheriting model. + :return: The string to be used as the 'name' field in ir.model.data. + Return None or False if an XML ID cannot/should not be generated + for this specific record based on its current state. + :rtype: str | None + """ + if self._name in [ + "l10n_br_fiscal.document.serie", + "l10n_br_fiscal.document.type", + ]: + return ( + None + ) # disable the mixin effect because it's cleary a user managed record + raise NotImplementedError( + _("Method `_get_xml_id_name` must be implemented in model %s.") % self._name + ) + + @api.model_create_multi + def create(self, vals_list): + if self.env.context.get("install_mode") or self.env.context.get("module"): + self = self.with_context(tracking_disable=True) # faster creation + return super().create(vals_list) + + records = super().create(vals_list) + if not records: + return records + + # Check which records already have an XML ID + new_ids = records.ids + DataModel = self.env["ir.model.data"] + _logger.info( + "Mixin Create: Searching ir.model.data for model '%s' and res_ids %s", + self._name, + new_ids, + ) + existing_data = DataModel.search( + [("model", "=", self._name), ("res_id", "in", new_ids)] + ) + ids_with_xmlid = set(existing_data.mapped("res_id")) + + # Filter records needing an XML ID + records_to_process = records.filtered(lambda r: r.id not in ids_with_xmlid) + + # Process records needing an XML ID + if records_to_process: + DataModelSudo = DataModel.sudo() + for record in records_to_process: + try: + # Call the helper method for the specific record + self._create_missing_xml_id(record, DataModelSudo) + except Exception as e: + # Log error but continue with other records + _logger.error( + "Mixin Create: Error calling _create_missing_xml_id for " + "%s (%s): %s", + record._name, + record.id, + e, + exc_info=True, + ) + + return records + + def write(self, vals): + """Prevent writing values inconsistent with the record xml_id.""" + + if self.env.context.get("install_mode") or self.env.context.get("module"): + # self = self.with_context(tracking_disable=True) # faster write; risky? + return super().write(vals) + + existing_data = ( + self.env["ir.model.data"] + .sudo() + .search([("model", "=", self._name), ("res_id", "in", self.ids)]) + ) + original_ids = {} + for record in self: + original_ids[record.id] = tuple( + map( + lambda imd: imd.name, + filter(lambda imd: imd.res_id == record.id, existing_data), + ) + ) + + res = super().write(vals) + + for record in self: + if ( + original_ids[record.id] + and record._get_xml_id_name() is not None + and record._get_xml_id_name() != original_ids[record.id][0] + ): + raise UserError( + _( + "Writing these values %(vals)s is forbidden in this record " + "because this record is tracked by the xml_id " + "l10n_br_fiscal.%(xml_id_name)s and these values would " + "mean an xml_id like l10n_br_fiscal.%(new_xml_id_name)s " + "instead! So if you need a record with these values you " + "can archive this record and create a new one instead. " + "(The proper xml_id will be created by the l10n_br_fiscal " + "module).", + vals=vals, + xml_id_name=original_ids[record.id][0], + new_xml_id_name=record._get_xml_id_name(), + ) + ) + return res + + def action_archive(self): + if not self.env.user.has_group("l10n_br_fiscal.group_manager"): + raise AccessError(_("You don't have permission to archive records.")) + return super().action_archive() + + def action_unarchive(self): + if not self.env.user.has_group("l10n_br_fiscal.group_manager"): + raise AccessError(_("You don't have permission to unarchive records.")) + return super().action_unarchive() + + def button_set_update(self): + if ( + not self.env.user.has_group("l10n_br_fiscal.group_manager") + and not self.env.user._is_superuser() + and not self.env.user._is_system() + ): + raise AccessError(_("You don't have permission to set records for update!")) + + imds = ( + self.env["ir.model.data"] + .sudo() + .search( + [ + ("model", "=", self._name), + ("res_id", "in", self.ids), + ("noupdate", "=", True), + ] + ) + ) + _logger.warning( + f"Toogle noupdate = True for {self._name} records {imds.mapped('res_id')}" + ) + return imds.sudo().write({"noupdate": False}) + + def button_set_noupdate(self): + if ( + not self.env.user.has_group("l10n_br_fiscal.group_manager") + and not self.env.user._is_superuser() + and not self.env.user._is_system() + ): + raise AccessError(_("You don't have permission to disable records update!")) + + imds = ( + self.env["ir.model.data"] + .sudo() + .search( + [ + ("model", "=", self._name), + ("res_id", "in", self.ids), + ("noupdate", "=", False), + ] + ) + ) + _logger.warning( + f"Toogle noupdate = False for {self._name} records {imds.mapped('res_id')}" + ) + return imds.sudo().write({"noupdate": True}) + + def _create_missing_xml_id(self, record, DataModel): + """Internal helper to create the ir.model.data record.""" + record.ensure_one() + + xml_id_name = record._get_xml_id_name() + if xml_id_name: + # Check if the specific name already exists + if DataModel.search_count( + [("module", "=", self._xml_id_module), ("name", "=", xml_id_name)] + ): + return + + DataModel.create( + { + "module": self._xml_id_module, + "name": xml_id_name, + "model": record._name, + "res_id": record.id, + "noupdate": True, + } + ) + + # --- Utility Methods --- + + def get_records_without_xmlid(self): + """ + Returns a recordset containing only the records from self + that do not have a corresponding ir.model.data entry. + + :param self: The input recordset. + :return: A recordset of the same model. + :rtype: odoo.models.Model + """ + if not self: + return self.browse() + + record_ids = self.ids + if not record_ids: + return self.browse() + + data_model = self.env["ir.model.data"] + existing_data = data_model.search( + [("model", "=", self._name), ("res_id", "in", record_ids)] + ) + ids_with_xmlid = set(existing_data.mapped("res_id")) + + ids_without_xmlid = [rid for rid in record_ids if rid not in ids_with_xmlid] + + # Return as recordset for easier chaining/operations + return self.browse(ids_without_xmlid) + + def fill_missing_xml_ids(self): + """ + Finds records in the current recordset (`self`) without an XML ID + and attempts to create one based on the model's pattern. + + Use this for backfilling records created before this mixin was active. + Example Usage: self.env['your.model'].search([]).fill_missing_xml_ids() + """ + records_to_process = self.get_records_without_xmlid() + DataModelSudo = self.env["ir.model.data"].sudo() + + for record in records_to_process: + if not DataModelSudo.search_count( + [("model", "=", record._name), ("res_id", "=", record.id)] + ): + self._create_missing_xml_id(record, DataModelSudo) diff --git a/l10n_br_fiscal/models/nbs.py b/l10n_br_fiscal/models/nbs.py index 40517b20d18d..f2682321606a 100644 --- a/l10n_br_fiscal/models/nbs.py +++ b/l10n_br_fiscal/models/nbs.py @@ -33,3 +33,7 @@ class Nbs(models.Model): def _get_ibpt(self, config, code_unmasked): return get_ibpt_service(config, code_unmasked) + + def _get_xml_id_name(self): + self.ensure_one() + return f"nbs_{self.code.replace('.', '')}" diff --git a/l10n_br_fiscal/models/ncm.py b/l10n_br_fiscal/models/ncm.py index a94ed117cebd..4c2c2e82d966 100644 --- a/l10n_br_fiscal/models/ncm.py +++ b/l10n_br_fiscal/models/ncm.py @@ -76,3 +76,10 @@ class Ncm(models.Model): def _get_ibpt(self, config, code_unmasked): return get_ibpt_product(config, code_unmasked) + + def _get_xml_id_name(self): + self.ensure_one() + return ( + f"ncm_{self.code.replace('.', '')}" + f"{self.exception and '_' + self.exception or ''}" + ) diff --git a/l10n_br_fiscal/models/service_type.py b/l10n_br_fiscal/models/service_type.py index 4abd8a9ea24e..069d93e786d8 100644 --- a/l10n_br_fiscal/models/service_type.py +++ b/l10n_br_fiscal/models/service_type.py @@ -2,7 +2,7 @@ # Copyright (C) 2014 KMEE - www.kmee.com.br # License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html -from odoo import fields, models +from odoo import _, fields, models class ServiceType(models.Model): @@ -43,3 +43,15 @@ class ServiceType(models.Model): ) withholding_possible = fields.Boolean(string="Subject to withholding tax") + + _sql_constraints = [ + ( + "fiscal_service_type_code_uniq", + "unique (code)", + _("service_type already exists with this code!"), + ) + ] + + def _get_xml_id_name(self): + self.ensure_one() + return f"service_type_{self.code.replace('.', '')}" diff --git a/l10n_br_fiscal/models/tax.py b/l10n_br_fiscal/models/tax.py index 853b0926d658..e49f28860561 100644 --- a/l10n_br_fiscal/models/tax.py +++ b/l10n_br_fiscal/models/tax.py @@ -1,6 +1,9 @@ # Copyright (C) 2013 Renato Lima - Akretion # License AGPL-3 - See http://www.gnu.org/licenses/agpl-3.0.html +import math +import re + from odoo import api, fields, models from odoo.tools import float_is_zero @@ -69,6 +72,7 @@ class Tax(models.Model): _name = "l10n_br_fiscal.tax" + _inherit = "l10n_br_fiscal.data.editable.mixin" _order = "sequence, tax_domain, name" _description = "Fiscal Tax" @@ -732,3 +736,65 @@ def _compute_tax_base_type(self): tax.tax_base_type = ICMS_ST_BASE_TYPE_REL.get(tax.icmsst_base_type) elif tax.tax_base_type is None: tax.tax_base_type = False + + def _get_xml_id_name(self): + """ + Generate XML ID name like: tax_pis_value_0_0211. + It works for 98% of the cases, the common + cases for which the user may create a missing tax record. + Some other cases like tax_icms_isento or tax_csll_nt don't + follow a common pattern, but they are rare exception the user is + not expect to create manually. + """ + + def string_repr(number): + integer_part = int(number) + if float_is_zero(number - integer_part, 4): + return str(integer_part) + else: + decimal_digits = str( + int(round((number - integer_part) * 1000000)) + ).rstrip("0") + zeros = -int(math.log10(number - integer_part)) + decimal_str = f"{'0' * zeros}{decimal_digits}" + return f"{integer_part}_{decimal_str}" + + self.ensure_one() + if not self.tax_domain: + return None + + # PIS / COFINS + if "pis" in self.tax_domain or "cofins" in self.tax_domain: + match = re.search(r"sico (\d+\.?\d*)", self.name.replace(",", ".")) + if not match: + match = re.search(r"sico R\$ (\d+\.?\d*)", self.name.replace(",", ".")) + if match: + if self.percent_amount: + return ( + f"tax_{self.tax_domain}_monofasico_" + f"{string_repr(self.percent_amount)}" + ) + elif self.value_amount: + return ( + f"tax_{self.tax_domain}_value_" + f"{string_repr(self.value_amount)}" + ) + + match = re.search( + r"Aliq. Dif (\d+\.?\d*)% Crédito (\d+\.?\d*)%", self.name.replace(",", ".") + ) + if match: + diff = float(match.group(1)) # 0.0 + credit = float(match.group(2)) # 0.0 + return ( + f"tax_{self.tax_domain}_aliqdif_" + f"{string_repr(diff)}_cred_{string_repr(credit)}" + ) + + # OTHERS + if self.percent_reduction: + return ( + f"tax_{self.tax_domain}_{string_repr(self.percent_amount)}_" + f"red_{string_repr(self.percent_reduction)}" + ) + return f"tax_{self.tax_domain}_{string_repr(self.percent_amount)}" diff --git a/l10n_br_fiscal/models/tax_pis_cofins.py b/l10n_br_fiscal/models/tax_pis_cofins.py index 35a9fbebbdae..4168e0df53f5 100644 --- a/l10n_br_fiscal/models/tax_pis_cofins.py +++ b/l10n_br_fiscal/models/tax_pis_cofins.py @@ -93,3 +93,7 @@ def _compute_ncms(self): if domain: r.ncm_ids = ncm.search(domain) + + def _get_xml_id_name(self): + self.ensure_one() + return None # TODO FIXME diff --git a/l10n_br_fiscal/tests/__init__.py b/l10n_br_fiscal/tests/__init__.py index f0cd8aba5acf..4fb1e08194bc 100644 --- a/l10n_br_fiscal/tests/__init__.py +++ b/l10n_br_fiscal/tests/__init__.py @@ -1,6 +1,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from . import ( + test_data_editable_mixin, test_cnae, test_fiscal_document_generic, test_fiscal_document_nfse, diff --git a/l10n_br_fiscal/tests/test_data_editable_mixin.py b/l10n_br_fiscal/tests/test_data_editable_mixin.py new file mode 100644 index 000000000000..2a84dbf6f0a5 --- /dev/null +++ b/l10n_br_fiscal/tests/test_data_editable_mixin.py @@ -0,0 +1,362 @@ +# Copyright 2025-TODAY Akretion - Raphael Valyi +# License AGPL-3.0 or later (https://www.gnu.org/licenses/lgpl-3.0.en.html). + +import logging + +from odoo.exceptions import AccessError, UserError +from odoo.tests.common import TransactionCase +from odoo.tools import mute_logger + +_logger = logging.getLogger(__name__) + + +class TestIrModelDataEditableMixinOnNcm(TransactionCase): + """ + Tests for the l10n_br_fiscal.data.editable.mixin integrated + with the l10n_br_fiscal.ncm model. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env( + context=dict( + cls.env.context, + tracking_disable=True, + ) + ) + cls.Ncm = cls.env["l10n_br_fiscal.ncm"] + cls.ncm_model_name = "l10n_br_fiscal.ncm" + cls.ncm_module_name = "l10n_br_fiscal" + cls.IrModelData = cls.env["ir.model.data"].sudo() + + def _create_ncm(self, code_suffix, name_suffix=None, **kwargs): + code = f"9999.99.{code_suffix:02d}" + name = f"Test NCM {name_suffix or code_suffix}" + vals = {"code": code, "name": name, **kwargs} + return self.Ncm.create(vals) + + def _get_xml_id(self, record): + return self.IrModelData.search( + [ + ("module", "=", self.ncm_module_name), + ("model", "=", self.ncm_model_name), + ("res_id", "=", record.id), + ] + ) + + # --- Test Methods Adjusted Below --- + + def test_01_create_generates_xmlid(self): + """Test that creating an NCM record generates an ir.model.data entry.""" + # This test should now pass after fixing the mixin's create method + record_code_suffix = 1 + record = self._create_ncm(record_code_suffix) + expected_xml_id_name = f"ncm_{record.code.replace('.', '')}" + + self.assertTrue(record, "NCM Record should be created") + xml_id_record = self._get_xml_id(record) + self.assertEqual( + len(xml_id_record), 1, "Exactly one XML ID should be created for NCM" + ) + self.assertEqual( + xml_id_record.name, + expected_xml_id_name, + "NCM XML ID name should match the pattern", + ) + self.assertTrue(xml_id_record.noupdate, "XML ID noupdate flag should be True") + + def test_02_create_skips_existing_xmlid(self): + """ + Test that create does not create a duplicate if XML ID + already exists for NCM. + """ + record_code_suffix = 2 + manual_xml_id_name = f"manual_ncm_{record_code_suffix}" + + # Create NCM first + record = self._create_ncm(record_code_suffix) + # Manually remove the auto-generated one (if any) to simulate import + self._get_xml_id(record).unlink() + + # Manually create an ir.model.data + manual_xml_id = self.IrModelData.create( + { + "module": self.ncm_module_name, + "name": manual_xml_id_name, + "model": self.ncm_model_name, + "res_id": record.id, + "noupdate": True, + } + ) + self.assertTrue(manual_xml_id, "Manual XML ID should be created") + + # Verify no duplicate was made and manual one remains + xml_id_records = self._get_xml_id(record) + self.assertEqual( + len(xml_id_records), 1, "Still exactly one XML ID should exist for NCM" + ) + self.assertEqual( + xml_id_records.name, + manual_xml_id_name, + "XML ID name should be the manually created one", + ) + self.assertTrue( + xml_id_records.noupdate, + "XML ID noupdate flag should be the manually set one (True)", + ) + + def test_03_create_skips_in_test_mode(self): + """Test that create *does not* skip just for test_enable=True.""" + # This test's premise changes: test_enable should NOT prevent creation. + # The skipping happens for install_mode=True or module=True in context. + # We can rename or repurpose this test to ensure normal creation works. + # Or simply trust test_01 covers normal creation. Let's keep it simple: + # Test that default test creation *does* create an ID. + record = self._create_ncm(3) + self.assertTrue(record) + xml_id_record = self._get_xml_id(record) + # ASSERTION IS NOW assertTrue + self.assertTrue(xml_id_record, "XML ID *should* be created in normal test mode") + + def test_04_create_skips_in_install_mode(self): + """ + Test that create *does* skip XML ID gen when + context indicates install mode. + """ + # This test remains valid as it checks the context key + record = self.Ncm.with_context(install_mode=True).create( + {"code": "9999.99.04", "name": "Test NCM 04 Install"} + ) + self.assertTrue(record) + xml_id_record = self._get_xml_id(record) + self.assertFalse( + xml_id_record, "No XML ID should be created for NCM in install mode" + ) + + def test_05_get_records_without_xmlid(self): + """Test the get_records_without_xmlid method on NCM.""" + # Record 1: Gets XML ID automatically + rec1 = self._create_ncm(61) + # Record 2: Create WITH code, but manually remove its XML ID + rec2 = self._create_ncm(62) + rec2_xml_id = self._get_xml_id(rec2) + self.assertTrue(rec2_xml_id, "Rec2 should initially get an XML ID") + rec2_xml_id.unlink() # Remove it for the test + + # Record 3: Manually add XML ID (after removing auto-one) + rec3 = self._create_ncm(63) + self._get_xml_id(rec3).unlink() # Remove auto-one first + self.IrModelData.create( + { + "module": self.ncm_module_name, + "name": "manual_ncm_63", + "model": self.ncm_model_name, + "res_id": rec3.id, + } + ) + + all_records = rec1 | rec2 | rec3 + + # Verify state before calling the method + self.assertTrue(self._get_xml_id(rec1), "Rec1 should have XML ID") + self.assertFalse( + self._get_xml_id(rec2), "Rec2 should NOT have XML ID (manually removed)" + ) + self.assertTrue(self._get_xml_id(rec3), "Rec3 should have manual XML ID") + + # Call the method + records_without_id = all_records.get_records_without_xmlid() + + # Assertions + self.assertEqual( + len(records_without_id), 1, "Should find exactly one record without XML ID" + ) + self.assertEqual( + records_without_id.id, rec2.id, "The record found should be rec2" + ) + + def test_06_fill_missing_xml_ids(self): + """Test the fill_missing_xml_ids utility method on NCM.""" + # Record 1: Create WITH code, manually remove XML ID. This one should be filled. + rec1 = self._create_ncm(71) + rec1_xml_id = self._get_xml_id(rec1) + self.assertTrue(rec1_xml_id, "Rec1 should initially get an XML ID") + rec1_xml_id.unlink() # Remove it + expected_xml_id_name_rec1 = f"ncm_{rec1.code.replace('.', '')}" + + # Record 2: Create WITH code, keep its XML ID. This one should NOT be touched. + rec2 = self._create_ncm(72) + self.assertTrue(self._get_xml_id(rec2), "Rec2 should have XML ID and keep it") + + # Record 3: Will test skipping if pattern returns None (if possible) + # For NCM, this is hard to test as code is required. Let's omit for now. + + records_to_check = rec1 | rec2 + + # Call the fill method + records_to_check.fill_missing_xml_ids() + + # Check XML IDs after filling + xml_id_rec1_after = self._get_xml_id(rec1) + self.assertTrue(xml_id_rec1_after, "Rec1 should now have an XML ID after fill") + self.assertEqual( + xml_id_rec1_after.name, + expected_xml_id_name_rec1, + "Rec1 XML ID name should be correct", + ) + self.assertTrue(xml_id_rec1_after.noupdate, "Rec1 noupdate should be True") + + self.assertTrue( + self._get_xml_id(rec2), "Rec2 should still have its original XML ID" + ) + + def test_07_write_does_not_create_xmlid(self): + """Test that write() itself does not trigger XML ID creation on NCM.""" + # Setup: Create record WITH code, remove its XML ID + record = self._create_ncm(81) + initial_xml_id = self._get_xml_id(record) + self.assertTrue(initial_xml_id, "Record should have XML ID initially") + initial_xml_id.unlink() + self.assertFalse( + self._get_xml_id(record), + "Record should not have XML ID after manual removal", + ) + + # Write some other field (e.g., name) + record.write({"name": "Updated NCM Name 81"}) + + # Verify that write did NOT create the ID + self.assertFalse( + self._get_xml_id(record), "Record should still not have XML ID after write" + ) + + # Verify fill *does* work now + record.fill_missing_xml_ids() + self.assertTrue( + self._get_xml_id(record), "Record should have XML ID after fill" + ) + + def test_08_write_does_allow_conflicting_values(self): + """ + Test that write() will not allow writing values + inconsistent with the xml_id + """ + record = self._create_ncm(81) + initial_xml_id = self._get_xml_id(record) + self.assertTrue(initial_xml_id, "Record should have XML ID initially") + with self.assertRaises(UserError): + record.write({"code": "badcode"}) + + @mute_logger("odoo.addons.l10n_br_fiscal.models.data_editable_mixin") + def test_09_update_noupdate(self): + """Test update / nopupdate toggle""" + record = self._create_ncm(81) + + user = self.env["res.users"].create( + { + "name": "Fiscal User", + "login": "test_editable_data_user", + "password": "admin", + "groups_id": [ + (4, self.env.ref("l10n_br_fiscal.group_user").id), + ], + } + ) + with self.assertRaises(AccessError): + record.with_user(user).button_set_update() + with self.assertRaises(AccessError): + record.with_user(user).button_set_noupdate() + + manager = self.env["res.users"].create( + { + "name": "Fiscal Manager", + "login": "test_editable_data_manager", + "password": "admin", + "groups_id": [ + (4, self.env.ref("l10n_br_fiscal.group_manager").id), + ], + } + ) + record.with_user(manager).button_set_update() + self.assertFalse(self._get_xml_id(record).noupdate) + record.with_user(manager).button_set_noupdate() + self.assertTrue(self._get_xml_id(record).noupdate) + + def test_10_archive_unarchive(self): + """Test update / nopupdate toggle""" + record = self._create_ncm(81) + + user = self.env["res.users"].create( + { + "name": "Fiscal User", + "login": "test_editable_data_user", + "password": "admin", + "groups_id": [ + (4, self.env.ref("l10n_br_fiscal.group_user").id), + ], + } + ) + with self.assertRaises(AccessError): + record.with_user(user).action_archive() + + manager = self.env["res.users"].create( + { + "name": "Fiscal Manager", + "login": "test_editable_data_manager", + "password": "admin", + "groups_id": [ + (4, self.env.ref("l10n_br_fiscal.group_manager").id), + ], + } + ) + record.with_user(manager).action_archive() + self.assertFalse(record.active) + record.with_user(manager).action_unarchive() + self.assertTrue(record.active) + + def test_11_various_xml_id_names(self): + """Test the xml ids of some classes were the mixin is injected""" + self.assertEqual( + self.env.ref("l10n_br_fiscal.tax_icms_16")._get_xml_id_name(), "tax_icms_16" + ) + self.assertEqual( + self.env.ref("l10n_br_fiscal.tax_icms_12_red_26_57")._get_xml_id_name(), + "tax_icms_12_red_26_57", + ) + self.assertEqual( + self.env.ref("l10n_br_fiscal.tax_pis_value_10_39")._get_xml_id_name(), + "tax_pis_value_10_39", + ) + self.assertEqual( + self.env.ref("l10n_br_fiscal.tax_pis_monofasico_1_67")._get_xml_id_name(), + "tax_pis_monofasico_1_67", + ) + self.assertEqual( + self.env.ref("l10n_br_fiscal.tax_pis_value_0_0211")._get_xml_id_name(), + "tax_pis_value_0_0211", + ) + self.assertEqual( + self.env.ref("l10n_br_fiscal.tax_ipi_33_86")._get_xml_id_name(), + "tax_ipi_33_86", + ) + + self.assertEqual( + self.env.ref("l10n_br_fiscal.service_type_404")._get_xml_id_name(), + "service_type_404", + ) + self.assertEqual( + self.env.ref("l10n_br_fiscal.cst_icmssn_300")._get_xml_id_name(), + "cst_icmssn_300", + ) + self.assertEqual( + self.env.ref("l10n_br_fiscal.cnae_0116402")._get_xml_id_name(), + "cnae_0116402", + ) + self.assertEqual( + self.env.ref("l10n_br_fiscal.cfop_1120")._get_xml_id_name(), "cfop_1120" + ) + self.assertEqual( + self.env.ref("l10n_br_fiscal.nbs_114021100")._get_xml_id_name(), + "nbs_114021100", + )