From eea85e4d6630b80a03c208324643b903433abe82 Mon Sep 17 00:00:00 2001 From: JordiMForgeFlow Date: Thu, 23 Apr 2026 09:53:31 +0200 Subject: [PATCH] [IMP] product_abc_classification: add multi-company support --- product_abc_classification/README.rst | 6 +- product_abc_classification/__manifest__.py | 3 +- .../migrations/18.0.1.1.0/post-migration.py | 26 ++++++ .../abc_classification_product_level.py | 17 ++++ .../models/abc_classification_profile.py | 28 +++++++ .../models/product_product.py | 1 + .../models/product_template.py | 1 + .../security/security.xml | 19 +++++ .../static/description/index.html | 28 +++---- product_abc_classification/tests/__init__.py | 1 + .../tests/test_multi_company.py | 83 +++++++++++++++++++ .../views/abc_classification_profile.xml | 5 ++ 12 files changed, 195 insertions(+), 23 deletions(-) create mode 100644 product_abc_classification/migrations/18.0.1.1.0/post-migration.py create mode 100644 product_abc_classification/security/security.xml create mode 100644 product_abc_classification/tests/test_multi_company.py diff --git a/product_abc_classification/README.rst b/product_abc_classification/README.rst index cc67bd9ddd4..02fdb720416 100644 --- a/product_abc_classification/README.rst +++ b/product_abc_classification/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ========================== Product Abc Classification ========================== @@ -17,7 +13,7 @@ Product Abc Classification .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png :target: https://odoo-community.org/page/development-status :alt: Beta -.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html :alt: License: AGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fproduct--attribute-lightgray.png?logo=github diff --git a/product_abc_classification/__manifest__.py b/product_abc_classification/__manifest__.py index e4a4ea074ab..0b7f47ab15b 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.1.0.0", + "version": "18.0.1.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/migrations/18.0.1.1.0/post-migration.py b/product_abc_classification/migrations/18.0.1.1.0/post-migration.py new file mode 100644 index 00000000000..df84acdd11a --- /dev/null +++ b/product_abc_classification/migrations/18.0.1.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 996186af797..e88574b20f9 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 bfaff2880f6..82441c28b52 100644 --- a/product_abc_classification/static/description/index.html +++ b/product_abc_classification/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Product Abc Classification -
+
+

Product Abc Classification

- - -Odoo Community Association - -
-

Product Abc Classification

-

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

+

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 classification) addons. These classification are used by inventory management teams to help identify the most important products in their @@ -400,7 +395,7 @@

Product Abc Classification

-

Usage

+

Usage

To use this module, you need to:

#. Go to Inventory menu, then to Configuration/Products/ABC Classification Profile and create a profile with levels, knowing that @@ -413,7 +408,7 @@

Usage

child categories and products will be profiled (or unprofiled).

-

Bug Tracker

+

Bug Tracker

Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. If you spotted it first, help us to smash it by providing a detailed and welcomed @@ -421,16 +416,16 @@

Bug Tracker

Do not contact contributors directly about support or help with technical issues.

-

Credits

+

Credits

-

Authors

+

Authors

  • ACSONE SA/NV
  • ForgeFlow
-

Contributors

+

Contributors

-

Other credits

+

Other credits

The migration of this module from 17.0 to 18.0 was financially supported by Camptocamp

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -458,6 +453,5 @@

Maintainers

-
diff --git a/product_abc_classification/tests/__init__.py b/product_abc_classification/tests/__init__.py index 8292c06ca32..9f90146243a 100644 --- a/product_abc_classification/tests/__init__.py +++ b/product_abc_classification/tests/__init__.py @@ -1,3 +1,4 @@ from . import test_abc_classification_product_level from . import test_abc_classification_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 @@ +