diff --git a/README.md b/README.md index 6f75bb27ce3..7cdfec7f28d 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ addon | version | maintainers | summary [nutritional_info](nutritional_info/) | 18.0.1.0.0 | CarlosRoca13 | Nutritional information. [nutritional_info_stock_lot](nutritional_info_stock_lot/) | 18.0.1.0.0 | CarlosRoca13 | Nutritional information for lots. [pos_product_cost_security](pos_product_cost_security/) | 18.0.1.0.0 | | Compatibility between Point of Sale and Product Cost Security -[product_abc_classification](product_abc_classification/) | 18.0.2.0.0 | | ABC classification for sales and warehouse management +[product_abc_classification](product_abc_classification/) | 18.0.2.1.0 | | ABC classification for sales and warehouse management [product_abc_classification_sale_stock](product_abc_classification_sale_stock/) | 18.0.1.0.0 | rousseldenis lmignon lmarion-source | Compute ABC classification from the number of delivered sale order line by product [product_assortment](product_assortment/) | 18.0.1.0.1 | | Adds the ability to manage products assortment [product_attachment_zipped_download](product_attachment_zipped_download/) | 18.0.1.0.0 | victoralmau | Product Attachment Zipped Download diff --git a/product_abc_classification/README.rst b/product_abc_classification/README.rst index a315c8202fa..52459d95149 100644 --- a/product_abc_classification/README.rst +++ b/product_abc_classification/README.rst @@ -11,7 +11,7 @@ Product Abc Classification !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:7449408f8b05c5c6727fa26edf48fe6059b4c340378555629377ca12e0bb0b6b + !! source digest: sha256:0b89f1d5229ad6e783124e9478615b65f3122c6ca9c6a4da1445205147b44da2 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png diff --git a/product_abc_classification/__manifest__.py b/product_abc_classification/__manifest__.py index fa4481d755d..dccee7bd4cf 100644 --- a/product_abc_classification/__manifest__.py +++ b/product_abc_classification/__manifest__.py @@ -6,7 +6,7 @@ "name": "Product Abc Classification", "summary": """ ABC classification for sales and warehouse management""", - "version": "18.0.2.0.0", + "version": "18.0.2.1.0", "license": "AGPL-3", "author": "ACSONE SA/NV, ForgeFlow, Odoo Community Association (OCA)", "website": "https://github.com/OCA/product-attribute", @@ -18,6 +18,7 @@ "views/product_product.xml", "views/product_category.xml", "security/ir.model.access.csv", + "security/security.xml", "data/ir_cron.xml", ], } diff --git a/product_abc_classification/i18n/es.po b/product_abc_classification/i18n/es.po index 042c8a52ec8..322405f7389 100644 --- a/product_abc_classification/i18n/es.po +++ b/product_abc_classification/i18n/es.po @@ -140,6 +140,12 @@ msgstr "El nivel de clasificación es obligatorio" msgid "Classification not in sync with computed" msgstr "Clasificación no sincronizada con el cálculo" +#. module: product_abc_classification +#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_product_level__company_id +#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_profile__company_id +msgid "Company" +msgstr "" + #. module: product_abc_classification #: model_terms:ir.ui.view,arch_db:product_abc_classification.abc_classification_profile_form_view msgid "Computation" @@ -398,6 +404,15 @@ msgstr "El nombre del perfil debe ser único" msgid "Ratings" msgstr "" +#. module: product_abc_classification +#. odoo-python +#: code:addons/product_abc_classification/models/abc_classification_profile.py:0 +msgid "" +"The ABC Classification Profile %(profile)s is assigned to company %" +"(company)s, but the following products belong to another company: %" +"(products)s." +msgstr "" + #. module: product_abc_classification #. odoo-python #: code:addons/product_abc_classification/models/abc_classification_level.py:0 diff --git a/product_abc_classification/i18n/fr.po b/product_abc_classification/i18n/fr.po index df3147a1df2..1da6dcead6a 100644 --- a/product_abc_classification/i18n/fr.po +++ b/product_abc_classification/i18n/fr.po @@ -140,6 +140,12 @@ msgstr "La classe / niveau est obligatoire" msgid "Classification not in sync with computed" msgstr "Classes ABC manuelle et calculée divergentes" +#. module: product_abc_classification +#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_product_level__company_id +#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_profile__company_id +msgid "Company" +msgstr "" + #. module: product_abc_classification #: model_terms:ir.ui.view,arch_db:product_abc_classification.abc_classification_profile_form_view msgid "Computation" @@ -404,6 +410,15 @@ msgstr "Le nom du profil doit être unique" msgid "Ratings" msgstr "" +#. module: product_abc_classification +#. odoo-python +#: code:addons/product_abc_classification/models/abc_classification_profile.py:0 +msgid "" +"The ABC Classification Profile %(profile)s is assigned to company %" +"(company)s, but the following products belong to another company: %" +"(products)s." +msgstr "" + #. module: product_abc_classification #. odoo-python #: code:addons/product_abc_classification/models/abc_classification_level.py:0 diff --git a/product_abc_classification/i18n/it.po b/product_abc_classification/i18n/it.po index 29a37782884..9f927382985 100644 --- a/product_abc_classification/i18n/it.po +++ b/product_abc_classification/i18n/it.po @@ -140,6 +140,12 @@ msgstr "Il livello classificazione è obbligatorio" msgid "Classification not in sync with computed" msgstr "Classificazione non sincronizzata con il calcolo" +#. module: product_abc_classification +#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_product_level__company_id +#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_profile__company_id +msgid "Company" +msgstr "" + #. module: product_abc_classification #: model_terms:ir.ui.view,arch_db:product_abc_classification.abc_classification_profile_form_view msgid "Computation" @@ -398,6 +404,15 @@ msgstr "Il nome profilo deve essere univoco" msgid "Ratings" msgstr "" +#. module: product_abc_classification +#. odoo-python +#: code:addons/product_abc_classification/models/abc_classification_profile.py:0 +msgid "" +"The ABC Classification Profile %(profile)s is assigned to company %" +"(company)s, but the following products belong to another company: %" +"(products)s." +msgstr "" + #. module: product_abc_classification #. odoo-python #: code:addons/product_abc_classification/models/abc_classification_level.py:0 diff --git a/product_abc_classification/i18n/product_abc_classification.pot b/product_abc_classification/i18n/product_abc_classification.pot index e08bff691b1..0a7f1f6cacd 100644 --- a/product_abc_classification/i18n/product_abc_classification.pot +++ b/product_abc_classification/i18n/product_abc_classification.pot @@ -135,6 +135,12 @@ msgstr "" msgid "Classification not in sync with computed" msgstr "" +#. module: product_abc_classification +#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_product_level__company_id +#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_profile__company_id +msgid "Company" +msgstr "" + #. module: product_abc_classification #: model_terms:ir.ui.view,arch_db:product_abc_classification.abc_classification_profile_form_view msgid "Computation" @@ -387,6 +393,15 @@ msgstr "" msgid "Ratings" msgstr "" +#. module: product_abc_classification +#. odoo-python +#: code:addons/product_abc_classification/models/abc_classification_profile.py:0 +msgid "" +"The ABC Classification Profile %(profile)s is assigned to company " +"%(company)s, but the following products belong to another company: " +"%(products)s." +msgstr "" + #. module: product_abc_classification #. odoo-python #: code:addons/product_abc_classification/models/abc_classification_level.py:0 diff --git a/product_abc_classification/migrations/18.0.2.1.0/post-migration.py b/product_abc_classification/migrations/18.0.2.1.0/post-migration.py new file mode 100644 index 00000000000..df84acdd11a --- /dev/null +++ b/product_abc_classification/migrations/18.0.2.1.0/post-migration.py @@ -0,0 +1,26 @@ +# Copyright 2026 ForgeFlow +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import logging + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + if not version: + return + cr.execute( + """ + UPDATE abc_classification_product_level AS lvl + SET company_id = tmpl.company_id + FROM product_product AS pp, + product_template AS tmpl + WHERE lvl.product_id = pp.id + AND pp.product_tmpl_id = tmpl.id + AND tmpl.company_id IS NOT NULL + AND lvl.company_id IS NULL; + """ + ) + _logger.info( + "Backfilled company_id on %s abc.classification.product.level rows", + cr.rowcount, + ) diff --git a/product_abc_classification/models/abc_classification_product_level.py b/product_abc_classification/models/abc_classification_product_level.py index 505642afb2d..bd6d13b38f0 100644 --- a/product_abc_classification/models/abc_classification_product_level.py +++ b/product_abc_classification/models/abc_classification_product_level.py @@ -10,6 +10,7 @@ class AbcClassificationProductLevel(models.Model): _inherit = "mail.thread" _description = "Abc Classification Product Level" _rec_name = "level_id" + _check_company_auto = True manual_level_id = fields.Many2one( "abc.classification.level", @@ -43,6 +44,7 @@ class AbcClassificationProductLevel(models.Model): index=True, required=True, ondelete="cascade", + check_company=True, ) product_tmpl_id = fields.Many2one( "product.template", @@ -50,11 +52,19 @@ class AbcClassificationProductLevel(models.Model): index=True, readonly=True, ) + company_id = fields.Many2one( + "res.company", + compute="_compute_company_id", + store=True, + readonly=True, + index=True, + ) # percentage profile_id = fields.Many2one( "abc.classification.profile", string="Profile", required=True, + check_company=True, ) profile_type = fields.Selection( related="profile_id.profile_type", @@ -124,6 +134,13 @@ def _compute_flag(self): rec.computed_level_id and rec.manual_level_id != rec.computed_level_id ) + @api.depends("product_id.company_id", "profile_id.company_id") + def _compute_company_id(self): + for rec in self: + rec.company_id = ( + rec.profile_id.company_id or rec.product_id.company_id or False + ) + @api.model_create_multi def create(self, vals_list): for vals in vals_list: diff --git a/product_abc_classification/models/abc_classification_profile.py b/product_abc_classification/models/abc_classification_profile.py index 7a7ef7da7a3..d1c2c174b5d 100644 --- a/product_abc_classification/models/abc_classification_profile.py +++ b/product_abc_classification/models/abc_classification_profile.py @@ -28,6 +28,10 @@ class AbcClassificationProfile(models.Model): string="Period on which to compute the classification (Days)", required=True, ) + company_id = fields.Many2one( + comodel_name="res.company", + string="Company", + ) product_variant_ids = fields.Many2many( comodel_name="product.product", @@ -47,6 +51,30 @@ class AbcClassificationProfile(models.Model): _sql_constraints = [("name_uniq", "UNIQUE(name)", "Profile name must be unique")] + @api.constrains("company_id", "product_variant_ids") + def _check_company_products(self): + for profile in self: + if not profile.company_id: + continue + bad = self.env["product.product"].search( + [ + ("id", "in", profile.product_variant_ids.ids), + ("company_id", "!=", False), + ("company_id", "!=", profile.company_id.id), + ] + ) + if bad: + raise ValidationError( + self.env._( + "The ABC Classification Profile %(profile)s is assigned " + "to company %(company)s, but the following products " + "belong to another company: %(products)s.", + profile=profile.display_name, + company=profile.company_id.display_name, + products=", ".join(bad.mapped("display_name")), + ) + ) + @api.constrains("level_ids") def _check_levels(self): for profile in self: diff --git a/product_abc_classification/models/product_product.py b/product_abc_classification/models/product_product.py index 31c79263d73..07196ef15ee 100644 --- a/product_abc_classification/models/product_product.py +++ b/product_abc_classification/models/product_product.py @@ -17,6 +17,7 @@ class ProductProduct(models.Model): column1="product_id", column2="profile_id", index=True, + check_company=True, ) abc_classification_profile_updatable_from_category = fields.Boolean(default=True) diff --git a/product_abc_classification/models/product_template.py b/product_abc_classification/models/product_template.py index 48c01bbc47d..2f73ca74e40 100644 --- a/product_abc_classification/models/product_template.py +++ b/product_abc_classification/models/product_template.py @@ -13,6 +13,7 @@ class ProductTemplate(models.Model): compute="_compute_abc_classification_profile_ids", inverse="_inverse_abc_classification_profile_ids", store=True, + check_company=True, ) abc_classification_product_level_ids = fields.One2many( "abc.classification.product.level", diff --git a/product_abc_classification/security/security.xml b/product_abc_classification/security/security.xml new file mode 100644 index 00000000000..9663d5b74df --- /dev/null +++ b/product_abc_classification/security/security.xml @@ -0,0 +1,19 @@ + + + + ABC Classification Profile multi-company + + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + + ABC Classification Product Level multi-company + + + ['|', ('company_id', '=', False), ('company_id', 'in', company_ids)] + + diff --git a/product_abc_classification/static/description/index.html b/product_abc_classification/static/description/index.html index 9e2d357d09b..fae94832a2a 100644 --- a/product_abc_classification/static/description/index.html +++ b/product_abc_classification/static/description/index.html @@ -372,7 +372,7 @@

Product Abc Classification

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:7449408f8b05c5c6727fa26edf48fe6059b4c340378555629377ca12e0bb0b6b +!! source digest: sha256:0b89f1d5229ad6e783124e9478615b65f3122c6ca9c6a4da1445205147b44da2 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

Beta License: AGPL-3 OCA/product-attribute Translate me on Weblate Try me on Runboat

This modules provides the bases to build ABC analysis (or ABC diff --git a/product_abc_classification/tests/__init__.py b/product_abc_classification/tests/__init__.py index 644a77664ca..f1fc3141b68 100644 --- a/product_abc_classification/tests/__init__.py +++ b/product_abc_classification/tests/__init__.py @@ -2,3 +2,4 @@ from . import test_abc_classification_profile from . import test_abc_classification_manual_profile from . import test_product +from . import test_multi_company diff --git a/product_abc_classification/tests/test_multi_company.py b/product_abc_classification/tests/test_multi_company.py new file mode 100644 index 00000000000..8750d10f764 --- /dev/null +++ b/product_abc_classification/tests/test_multi_company.py @@ -0,0 +1,83 @@ +# Copyright 2026 ForgeFlow +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.exceptions import UserError, ValidationError +from odoo.tests.common import tagged + +from .common import ABCClassificationLevelCase + + +@tagged("post_install", "-at_install") +class TestMultiCompany(ABCClassificationLevelCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.company_a = cls.env["res.company"].create({"name": "Company A"}) + cls.company_b = cls.env["res.company"].create({"name": "Company B"}) + cls.product_in_a = cls.env["product.product"].create( + {"name": "Prod A", "company_id": cls.company_a.id} + ) + cls.product_in_b = cls.env["product.product"].create( + {"name": "Prod B", "company_id": cls.company_b.id} + ) + cls.product_shared = cls.env["product.product"].create({"name": "Prod Shared"}) + + def test_shared_profile_inherits_product_company(self): + self.classification_profile.company_id = False + self.product_in_a.abc_classification_profile_ids = self.classification_profile + self.classification_profile._compute_abc_classification() + level = self.ProductLevel.search( + [ + ("profile_id", "=", self.classification_profile.id), + ("product_id", "=", self.product_in_a.id), + ] + ) + self.assertEqual(level.company_id, self.company_a) + + def test_company_profile_overrides_product_company(self): + self.classification_profile.company_id = self.company_a + self.product_shared.abc_classification_profile_ids = self.classification_profile + self.classification_profile._compute_abc_classification() + level = self.ProductLevel.search( + [ + ("profile_id", "=", self.classification_profile.id), + ("product_id", "=", self.product_shared.id), + ] + ) + self.assertEqual(level.company_id, self.company_a) + + def test_profile_constraint_conflicting_products(self): + self.product_in_b.abc_classification_profile_ids = self.classification_profile + with self.assertRaises(ValidationError): + self.classification_profile.company_id = self.company_a + + def test_level_blocks_conflicting_profile_and_product(self): + self.classification_profile.company_id = self.company_a + with self.assertRaises(UserError): + self.ProductLevel.create( + { + "profile_id": self.classification_profile.id, + "product_id": self.product_in_b.id, + "manual_level_id": self.classification_level_a.id, + } + ) + + def test_cannot_change_product_company_with_conflicting_profile(self): + self.classification_profile.company_id = self.company_a + self.product_shared.abc_classification_profile_ids = self.classification_profile + with self.assertRaises(UserError): + self.product_shared.company_id = self.company_b + + def test_level_company_recomputes_on_profile_change(self): + self.classification_profile.company_id = False + self.product_shared.abc_classification_profile_ids = self.classification_profile + self.classification_profile._compute_abc_classification() + level = self.ProductLevel.search( + [ + ("profile_id", "=", self.classification_profile.id), + ("product_id", "=", self.product_shared.id), + ] + ) + self.assertFalse(level.company_id) + self.classification_profile.company_id = self.company_a + self.assertEqual(level.company_id, self.company_a) diff --git a/product_abc_classification/views/abc_classification_profile.xml b/product_abc_classification/views/abc_classification_profile.xml index 5af737940c6..69e9607fc03 100644 --- a/product_abc_classification/views/abc_classification_profile.xml +++ b/product_abc_classification/views/abc_classification_profile.xml @@ -32,6 +32,10 @@ + @@ -67,6 +71,7 @@ +