From 203295893b0b226a4671e29d3d4878d62b5ab800 Mon Sep 17 00:00:00 2001
From: mreficent
Date: Thu, 28 May 2020 13:35:47 +0200
Subject: [PATCH 01/22] [ADD] product_abc_classification
---
product_abc_classification/__init__.py | 1 +
product_abc_classification/__manifest__.py | 20 +++
product_abc_classification/data/ir_cron.xml | 13 ++
product_abc_classification/models/__init__.py | 4 +
.../models/abc_classification_level.py | 35 ++++
.../models/abc_classification_profile.py | 156 ++++++++++++++++++
.../models/product_category.py | 26 +++
.../models/product_product.py | 74 +++++++++
.../readme/CONTRIBUTORS.rst | 1 +
.../readme/DESCRIPTION.rst | 8 +
product_abc_classification/readme/USAGE.rst | 11 ++
.../security/ir.model.access.csv | 5 +
.../static/description/icon.png | Bin 0 -> 9455 bytes
.../views/abc_classification_view.xml | 69 ++++++++
.../views/product_view.xml | 94 +++++++++++
15 files changed, 517 insertions(+)
create mode 100644 product_abc_classification/__init__.py
create mode 100644 product_abc_classification/__manifest__.py
create mode 100644 product_abc_classification/data/ir_cron.xml
create mode 100644 product_abc_classification/models/__init__.py
create mode 100644 product_abc_classification/models/abc_classification_level.py
create mode 100644 product_abc_classification/models/abc_classification_profile.py
create mode 100644 product_abc_classification/models/product_category.py
create mode 100644 product_abc_classification/models/product_product.py
create mode 100644 product_abc_classification/readme/CONTRIBUTORS.rst
create mode 100644 product_abc_classification/readme/DESCRIPTION.rst
create mode 100644 product_abc_classification/readme/USAGE.rst
create mode 100644 product_abc_classification/security/ir.model.access.csv
create mode 100644 product_abc_classification/static/description/icon.png
create mode 100644 product_abc_classification/views/abc_classification_view.xml
create mode 100644 product_abc_classification/views/product_view.xml
diff --git a/product_abc_classification/__init__.py b/product_abc_classification/__init__.py
new file mode 100644
index 00000000000..0650744f6bc
--- /dev/null
+++ b/product_abc_classification/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/product_abc_classification/__manifest__.py b/product_abc_classification/__manifest__.py
new file mode 100644
index 00000000000..d67fd6bc574
--- /dev/null
+++ b/product_abc_classification/__manifest__.py
@@ -0,0 +1,20 @@
+# Copyright 2020 ForgeFlow
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
+{
+ "name": "Product ABC Classification",
+ "summary": "Includes ABC classification for inventory management",
+ "version": "13.0.1.0.0",
+ "author": "ForgeFlow, Odoo Community Association (OCA)",
+ "website": "https://github.com/OCA/product-attribute",
+ "category": "Inventory Management",
+ "license": "AGPL-3",
+ "maintainers": ["MiquelRForgeFlow"],
+ "depends": ["sale_stock"],
+ "data": [
+ "security/ir.model.access.csv",
+ "views/product_view.xml",
+ "views/abc_classification_view.xml",
+ "data/ir_cron.xml",
+ ],
+ "installable": True,
+}
diff --git a/product_abc_classification/data/ir_cron.xml b/product_abc_classification/data/ir_cron.xml
new file mode 100644
index 00000000000..36faa7971a5
--- /dev/null
+++ b/product_abc_classification/data/ir_cron.xml
@@ -0,0 +1,13 @@
+
+
+
+ Perform the product ABC Classification
+ 1
+ days
+ -1
+
+
+ model._compute_abc_classification()
+ code
+
+
diff --git a/product_abc_classification/models/__init__.py b/product_abc_classification/models/__init__.py
new file mode 100644
index 00000000000..39394627c87
--- /dev/null
+++ b/product_abc_classification/models/__init__.py
@@ -0,0 +1,4 @@
+from . import product_category
+from . import product_product
+from . import abc_classification_level
+from . import abc_classification_profile
diff --git a/product_abc_classification/models/abc_classification_level.py b/product_abc_classification/models/abc_classification_level.py
new file mode 100644
index 00000000000..2b6790c6b5b
--- /dev/null
+++ b/product_abc_classification/models/abc_classification_level.py
@@ -0,0 +1,35 @@
+# Copyright 2020 ForgeFlow
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
+
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError
+
+
+class ABCClasificationProfileLevel(models.Model):
+ _name = "abc.classification.profile.level"
+ _description = "ABC Clasification Profile Level"
+ _order = "percentage desc, id desc"
+
+ percentage = fields.Float(default=0.0, required=True, string="%")
+ profile_id = fields.Many2one("abc.classification.profile")
+
+ def name_get(self):
+ def _get_sort_key_percentage(rec):
+ return rec.percentage
+
+ res = []
+ for profile in self.mapped("profile_id"):
+ for i, level in enumerate(
+ profile.level_ids.sorted(key=_get_sort_key_percentage, reverse=True)
+ ):
+ name = "{} ({}%)".format(chr(65 + i), level.percentage)
+ res += [(level.id, name)]
+ return res
+
+ @api.constrains("percentage")
+ def _check_percentage(self):
+ for level in self:
+ if level.percentage > 100.0:
+ raise ValidationError(_("The percentage cannot be greater than 100."))
+ elif level.percentage <= 0.0:
+ raise ValidationError(_("The percentage should be a positive number."))
diff --git a/product_abc_classification/models/abc_classification_profile.py b/product_abc_classification/models/abc_classification_profile.py
new file mode 100644
index 00000000000..f75be657558
--- /dev/null
+++ b/product_abc_classification/models/abc_classification_profile.py
@@ -0,0 +1,156 @@
+# Copyright 2020 ForgeFlow
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
+from datetime import timedelta
+
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError
+
+
+class ABCClasificationProfile(models.Model):
+ _name = "abc.classification.profile"
+ _description = "ABC Clasification Profile"
+
+ name = fields.Char()
+ level_ids = fields.One2many(
+ comodel_name="abc.classification.profile.level", inverse_name="profile_id"
+ )
+ representation = fields.Char(compute="_compute_representation")
+ data_source = fields.Selection(
+ selection=[("stock_moves", "Stock Moves")],
+ default="stock_moves",
+ string="Data Source",
+ index=True,
+ required=True,
+ )
+ value_criteria = fields.Selection(
+ selection=[("consumption_value", "Consumption Value")],
+ # others: 'sales revenue', 'profitability', ...
+ default="consumption_value",
+ string="Value",
+ index=True,
+ required=True,
+ )
+ past_period = fields.Integer(
+ default=365, string="Past demand period (Days)", required=True
+ )
+
+ @api.depends("level_ids")
+ def _compute_representation(self):
+ def _get_sort_key_percentage(rec):
+ return rec.percentage
+
+ for profile in self:
+ profile.level_ids.sorted(key=_get_sort_key_percentage, reverse=True)
+ profile.representation = "/".join(
+ [str(x) for x in profile.level_ids.mapped("display_name")]
+ )
+
+ @api.constrains("level_ids")
+ def _check_levels(self):
+ for profile in self:
+ percentages = profile.level_ids.mapped("percentage")
+ total = sum(percentages)
+ if profile.level_ids and total != 100.0:
+ raise ValidationError(
+ _("The sum of the percentages of the levels should be 100.")
+ )
+ if profile.level_ids and len({}.fromkeys(percentages)) != len(percentages):
+ raise ValidationError(
+ _("The percentages of the levels must be unique.")
+ )
+
+ def write(self, vals):
+ return super().write(vals)
+
+ def _fill_initial_product_data(self, date):
+ product_list = []
+ if self.data_source == "stock_moves":
+ return self._fill_data_from_stock_moves(date, product_list)
+ else:
+ return product_list
+
+ def _fill_data_from_stock_moves(self, date, product_list):
+ self.ensure_one()
+ moves = (
+ self.env["stock.move"]
+ .sudo()
+ .read_group(
+ [
+ ("state", "=", "done"),
+ ("date", ">", date),
+ ("location_dest_id.usage", "=", "customer"),
+ ("location_id.usage", "!=", "customer"),
+ ("product_id.type", "=", "product"),
+ "|",
+ ("product_id.abc_classification_profile_id", "=", self.id),
+ "|",
+ ("product_id.categ_id.abc_classification_profile_id", "=", self.id),
+ (
+ "product_id.categ_id.parent_id.abc_classification_profile_id",
+ "=",
+ self.id,
+ ),
+ ],
+ ["product_id", "product_qty"],
+ ["product_id"],
+ )
+ )
+ for move in moves:
+ product_data = {
+ "product": self.env["product.product"].browse(move["product_id"][0]),
+ "units_sold": move["product_qty"],
+ }
+ product_list.append(product_data)
+ return product_list
+
+ def _get_inventory_product_value(self, data):
+ self.ensure_one()
+ if self.value_criteria == "consumption_value":
+ return data["unit_cost"] * data["units_sold"]
+ raise 0.0
+
+ @api.model
+ def _compute_abc_classification(self):
+ def _get_sort_key_value(data):
+ return data["value"]
+
+ def _get_sort_key_percentage(rec):
+ return rec.percentage
+
+ profiles = self.search([]).filtered(lambda p: p.level_ids)
+ for profile in profiles:
+ oldest_date = fields.Datetime.to_string(
+ fields.Datetime.today() - timedelta(days=profile.past_period)
+ )
+ totals = {
+ "units_sold": 0,
+ "value": 0.0,
+ }
+ product_list = profile._fill_initial_product_data(oldest_date)
+ for product_data in product_list:
+ product_data["unit_cost"] = product_data["product"].standard_price
+ totals["units_sold"] += product_data["units_sold"]
+ product_data["value"] = profile._get_inventory_product_value(
+ product_data
+ )
+ totals["value"] += product_data["value"]
+ product_list.sort(reverse=True, key=_get_sort_key_value)
+ levels = profile.level_ids.sorted(
+ key=_get_sort_key_percentage, reverse=True
+ )
+ percentages = levels.mapped("percentage")
+ level_percentage = list(zip(levels, percentages))
+ for product_data in product_list:
+ product_data["value_percentage"] = (
+ (100.0 * product_data["value"] / totals["value"])
+ if totals["value"]
+ else 0.0
+ )
+ while (
+ product_data["value_percentage"] < level_percentage[0][1]
+ and len(level_percentage) > 1
+ ):
+ level_percentage.pop(0)
+ product_data["product"].abc_classification_level_id = level_percentage[
+ 0
+ ][0]
diff --git a/product_abc_classification/models/product_category.py b/product_abc_classification/models/product_category.py
new file mode 100644
index 00000000000..df7348e554c
--- /dev/null
+++ b/product_abc_classification/models/product_category.py
@@ -0,0 +1,26 @@
+# Copyright 2020 ForgeFlow
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
+
+from odoo import api, fields, models
+
+
+class ProductCategory(models.Model):
+ _inherit = "product.category"
+
+ abc_classification_profile_id = fields.Many2one("abc.classification.profile")
+ product_variant_ids = fields.One2many("product.product", inverse_name="categ_id")
+
+ @api.onchange("abc_classification_profile_id")
+ def _onchange_abc_classification_profile_id(self):
+ for categ in self:
+ for child in categ._origin.child_id:
+ child.abc_classification_profile_id = (
+ categ.abc_classification_profile_id
+ )
+ child._onchange_abc_classification_profile_id()
+ for variant in categ._origin.product_variant_ids.filtered(
+ lambda p: p.type == "product"
+ ):
+ variant.abc_classification_profile_id = (
+ categ.abc_classification_profile_id
+ )
diff --git a/product_abc_classification/models/product_product.py b/product_abc_classification/models/product_product.py
new file mode 100644
index 00000000000..99ce9470481
--- /dev/null
+++ b/product_abc_classification/models/product_product.py
@@ -0,0 +1,74 @@
+# Copyright 2020 ForgeFlow
+# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
+
+from odoo import api, fields, models
+
+
+class ProductTemplate(models.Model):
+ _inherit = "product.template"
+
+ abc_classification_profile_id = fields.Many2one(
+ "abc.classification.profile",
+ compute="_compute_abc_classification_profile_id",
+ inverse="_inverse_abc_classification_profile_id",
+ store=True,
+ )
+ abc_classification_level_id = fields.Many2one(
+ "abc.classification.profile.level",
+ compute="_compute_abc_classification_level_id",
+ inverse="_inverse_abc_classification_level_id",
+ store=True,
+ )
+
+ @api.depends(
+ "product_variant_ids", "product_variant_ids.abc_classification_profile_id"
+ )
+ def _compute_abc_classification_profile_id(self):
+ unique_variants = self.filtered(
+ lambda template: len(template.product_variant_ids) == 1
+ )
+ for template in unique_variants:
+ template.abc_classification_profile_id = (
+ template.product_variant_ids.abc_classification_profile_id
+ )
+ for template in self - unique_variants:
+ template.abc_classification_profile_id = False
+
+ @api.depends(
+ "product_variant_ids", "product_variant_ids.abc_classification_level_id"
+ )
+ def _compute_abc_classification_level_id(self):
+ unique_variants = self.filtered(
+ lambda template: len(template.product_variant_ids) == 1
+ )
+ for template in unique_variants:
+ template.abc_classification_level_id = (
+ template.product_variant_ids.abc_classification_level_id
+ )
+ for template in self - unique_variants:
+ template.abc_classification_level_id = False
+
+ def _inverse_abc_classification_profile_id(self):
+ for template in self:
+ if len(template.product_variant_ids) == 1:
+ template.product_variant_ids.abc_classification_profile_id = (
+ template.abc_classification_profile_id
+ )
+
+ def _inverse_abc_classification_level_id(self):
+ for template in self:
+ if len(template.product_variant_ids) == 1:
+ template.product_variant_ids.abc_classification_level_id = (
+ template.abc_classification_level_id
+ )
+
+
+class ProductProduct(models.Model):
+ _inherit = "product.product"
+
+ abc_classification_profile_id = fields.Many2one(
+ "abc.classification.profile", index=True
+ )
+ abc_classification_level_id = fields.Many2one(
+ "abc.classification.profile.level", index=True
+ )
diff --git a/product_abc_classification/readme/CONTRIBUTORS.rst b/product_abc_classification/readme/CONTRIBUTORS.rst
new file mode 100644
index 00000000000..2e34e218a54
--- /dev/null
+++ b/product_abc_classification/readme/CONTRIBUTORS.rst
@@ -0,0 +1 @@
+* Miquel Raïch
diff --git a/product_abc_classification/readme/DESCRIPTION.rst b/product_abc_classification/readme/DESCRIPTION.rst
new file mode 100644
index 00000000000..b55e108765f
--- /dev/null
+++ b/product_abc_classification/readme/DESCRIPTION.rst
@@ -0,0 +1,8 @@
+This modules includes the ABC analysis (or ABC classification), which is
+used by inventory management teams to help identify the most important
+products in their portfolio and ensure they prioritize managing them above
+those less valuable.
+
+Managers will create a profile with several levels (percentages) and then the
+profiled products will automatically get a corresponding level using the
+ABC classification.
diff --git a/product_abc_classification/readme/USAGE.rst b/product_abc_classification/readme/USAGE.rst
new file mode 100644
index 00000000000..a66526e486c
--- /dev/null
+++ b/product_abc_classification/readme/USAGE.rst
@@ -0,0 +1,11 @@
+To use this module, you need to:
+
+#. Go to Sales or Inventory menu, then to Configuration/Products/ABC Classification Profile
+and create a profile with levels, knowing that the sum of all levels in the profile
+should sum 100 and all the levels should be different.
+
+#. Later you should go to product categories or product variants, and assign them a profile.
+Then the cron classification will proceed to assign to these products one of the profile's levels.
+
+NOTE: If you profile (or unprofile) a product category, then all its
+child categories and products will be profiled (or unprofiled).
diff --git a/product_abc_classification/security/ir.model.access.csv b/product_abc_classification/security/ir.model.access.csv
new file mode 100644
index 00000000000..421beb328a9
--- /dev/null
+++ b/product_abc_classification/security/ir.model.access.csv
@@ -0,0 +1,5 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_abc_classification_profile_user,abc.classification.profile.user,model_abc_classification_profile,base.group_user,1,0,0,0
+access_abc_classification_profile_manager,abc.classification.profile.manager,model_abc_classification_profile,base.group_system,1,1,1,1
+access_abc_classification_profile_level_user,abc.classification.profile.level.user,model_abc_classification_profile_level,base.group_user,1,0,0,0
+access_abc_classification_profile_level_manager,abc.classification.profile.level.manager,model_abc_classification_profile_level,base.group_system,1,1,1,1
diff --git a/product_abc_classification/static/description/icon.png b/product_abc_classification/static/description/icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d
GIT binary patch
literal 9455
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
z
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
Xviia!U7SGha1wx#SCgwmn*{w2TRX*I
literal 0
HcmV?d00001
diff --git a/product_abc_classification/views/abc_classification_view.xml b/product_abc_classification/views/abc_classification_view.xml
new file mode 100644
index 00000000000..001fd3b21e6
--- /dev/null
+++ b/product_abc_classification/views/abc_classification_view.xml
@@ -0,0 +1,69 @@
+
+
+
+
+ abc.classification.profile.form
+ abc.classification.profile
+
+
+
+
+
+ abc.classification.profile.tree
+ abc.classification.profile
+
+
+
+
+
+
+
+
+ ABC Classification Profile
+ abc.classification.profile
+ tree,form
+
+
+ Click to add a new profile.
+
+
+ This allows to create an ABC classification.
+
+
+
+
+
+
diff --git a/product_abc_classification/views/product_view.xml b/product_abc_classification/views/product_view.xml
new file mode 100644
index 00000000000..5caf6a28283
--- /dev/null
+++ b/product_abc_classification/views/product_view.xml
@@ -0,0 +1,94 @@
+
+
+
+
+
+ product.template.search (ABC Classification)
+ product.template
+
+
+
+
+
+
+
+
+
+ product.template.tree
+ product.template
+
+
+
+
+
+
+
+
+
+ product.product.tree
+ product.product
+
+
+
+
+
+
+
+
+
+ product.template.form (ABC Classification)
+ product.template
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ product.category.form (ABC Classification)
+ product.category
+
+
+
+
+
+
+
+
From 5a63ff452b462c443f4cbb095372437312b86e76 Mon Sep 17 00:00:00 2001
From: Lindsay Marion
Date: Tue, 26 Jan 2021 12:08:02 +0100
Subject: [PATCH 02/22] [IMP] product_abc_classification: Makes the
classification computation logic pluggable
---
product_abc_classification/__init__.py | 1 -
product_abc_classification/__manifest__.py | 20 ---
product_abc_classification/data/ir_cron.xml | 13 --
product_abc_classification/models/__init__.py | 4 -
.../models/abc_classification_level.py | 35 ----
.../models/abc_classification_profile.py | 156 ------------------
.../models/product_category.py | 26 ---
.../models/product_product.py | 74 ---------
.../readme/CONTRIBUTORS.rst | 1 -
.../readme/DESCRIPTION.rst | 8 -
product_abc_classification/readme/USAGE.rst | 11 --
.../security/ir.model.access.csv | 5 -
.../static/description/icon.png | Bin 9455 -> 0 bytes
.../views/abc_classification_view.xml | 69 --------
.../views/product_view.xml | 94 -----------
15 files changed, 517 deletions(-)
delete mode 100644 product_abc_classification/__init__.py
delete mode 100644 product_abc_classification/__manifest__.py
delete mode 100644 product_abc_classification/data/ir_cron.xml
delete mode 100644 product_abc_classification/models/__init__.py
delete mode 100644 product_abc_classification/models/abc_classification_level.py
delete mode 100644 product_abc_classification/models/abc_classification_profile.py
delete mode 100644 product_abc_classification/models/product_category.py
delete mode 100644 product_abc_classification/models/product_product.py
delete mode 100644 product_abc_classification/readme/CONTRIBUTORS.rst
delete mode 100644 product_abc_classification/readme/DESCRIPTION.rst
delete mode 100644 product_abc_classification/readme/USAGE.rst
delete mode 100644 product_abc_classification/security/ir.model.access.csv
delete mode 100644 product_abc_classification/static/description/icon.png
delete mode 100644 product_abc_classification/views/abc_classification_view.xml
delete mode 100644 product_abc_classification/views/product_view.xml
diff --git a/product_abc_classification/__init__.py b/product_abc_classification/__init__.py
deleted file mode 100644
index 0650744f6bc..00000000000
--- a/product_abc_classification/__init__.py
+++ /dev/null
@@ -1 +0,0 @@
-from . import models
diff --git a/product_abc_classification/__manifest__.py b/product_abc_classification/__manifest__.py
deleted file mode 100644
index d67fd6bc574..00000000000
--- a/product_abc_classification/__manifest__.py
+++ /dev/null
@@ -1,20 +0,0 @@
-# Copyright 2020 ForgeFlow
-# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
-{
- "name": "Product ABC Classification",
- "summary": "Includes ABC classification for inventory management",
- "version": "13.0.1.0.0",
- "author": "ForgeFlow, Odoo Community Association (OCA)",
- "website": "https://github.com/OCA/product-attribute",
- "category": "Inventory Management",
- "license": "AGPL-3",
- "maintainers": ["MiquelRForgeFlow"],
- "depends": ["sale_stock"],
- "data": [
- "security/ir.model.access.csv",
- "views/product_view.xml",
- "views/abc_classification_view.xml",
- "data/ir_cron.xml",
- ],
- "installable": True,
-}
diff --git a/product_abc_classification/data/ir_cron.xml b/product_abc_classification/data/ir_cron.xml
deleted file mode 100644
index 36faa7971a5..00000000000
--- a/product_abc_classification/data/ir_cron.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-
-
-
- Perform the product ABC Classification
- 1
- days
- -1
-
-
- model._compute_abc_classification()
- code
-
-
diff --git a/product_abc_classification/models/__init__.py b/product_abc_classification/models/__init__.py
deleted file mode 100644
index 39394627c87..00000000000
--- a/product_abc_classification/models/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from . import product_category
-from . import product_product
-from . import abc_classification_level
-from . import abc_classification_profile
diff --git a/product_abc_classification/models/abc_classification_level.py b/product_abc_classification/models/abc_classification_level.py
deleted file mode 100644
index 2b6790c6b5b..00000000000
--- a/product_abc_classification/models/abc_classification_level.py
+++ /dev/null
@@ -1,35 +0,0 @@
-# Copyright 2020 ForgeFlow
-# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
-
-from odoo import _, api, fields, models
-from odoo.exceptions import ValidationError
-
-
-class ABCClasificationProfileLevel(models.Model):
- _name = "abc.classification.profile.level"
- _description = "ABC Clasification Profile Level"
- _order = "percentage desc, id desc"
-
- percentage = fields.Float(default=0.0, required=True, string="%")
- profile_id = fields.Many2one("abc.classification.profile")
-
- def name_get(self):
- def _get_sort_key_percentage(rec):
- return rec.percentage
-
- res = []
- for profile in self.mapped("profile_id"):
- for i, level in enumerate(
- profile.level_ids.sorted(key=_get_sort_key_percentage, reverse=True)
- ):
- name = "{} ({}%)".format(chr(65 + i), level.percentage)
- res += [(level.id, name)]
- return res
-
- @api.constrains("percentage")
- def _check_percentage(self):
- for level in self:
- if level.percentage > 100.0:
- raise ValidationError(_("The percentage cannot be greater than 100."))
- elif level.percentage <= 0.0:
- raise ValidationError(_("The percentage should be a positive number."))
diff --git a/product_abc_classification/models/abc_classification_profile.py b/product_abc_classification/models/abc_classification_profile.py
deleted file mode 100644
index f75be657558..00000000000
--- a/product_abc_classification/models/abc_classification_profile.py
+++ /dev/null
@@ -1,156 +0,0 @@
-# Copyright 2020 ForgeFlow
-# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
-from datetime import timedelta
-
-from odoo import _, api, fields, models
-from odoo.exceptions import ValidationError
-
-
-class ABCClasificationProfile(models.Model):
- _name = "abc.classification.profile"
- _description = "ABC Clasification Profile"
-
- name = fields.Char()
- level_ids = fields.One2many(
- comodel_name="abc.classification.profile.level", inverse_name="profile_id"
- )
- representation = fields.Char(compute="_compute_representation")
- data_source = fields.Selection(
- selection=[("stock_moves", "Stock Moves")],
- default="stock_moves",
- string="Data Source",
- index=True,
- required=True,
- )
- value_criteria = fields.Selection(
- selection=[("consumption_value", "Consumption Value")],
- # others: 'sales revenue', 'profitability', ...
- default="consumption_value",
- string="Value",
- index=True,
- required=True,
- )
- past_period = fields.Integer(
- default=365, string="Past demand period (Days)", required=True
- )
-
- @api.depends("level_ids")
- def _compute_representation(self):
- def _get_sort_key_percentage(rec):
- return rec.percentage
-
- for profile in self:
- profile.level_ids.sorted(key=_get_sort_key_percentage, reverse=True)
- profile.representation = "/".join(
- [str(x) for x in profile.level_ids.mapped("display_name")]
- )
-
- @api.constrains("level_ids")
- def _check_levels(self):
- for profile in self:
- percentages = profile.level_ids.mapped("percentage")
- total = sum(percentages)
- if profile.level_ids and total != 100.0:
- raise ValidationError(
- _("The sum of the percentages of the levels should be 100.")
- )
- if profile.level_ids and len({}.fromkeys(percentages)) != len(percentages):
- raise ValidationError(
- _("The percentages of the levels must be unique.")
- )
-
- def write(self, vals):
- return super().write(vals)
-
- def _fill_initial_product_data(self, date):
- product_list = []
- if self.data_source == "stock_moves":
- return self._fill_data_from_stock_moves(date, product_list)
- else:
- return product_list
-
- def _fill_data_from_stock_moves(self, date, product_list):
- self.ensure_one()
- moves = (
- self.env["stock.move"]
- .sudo()
- .read_group(
- [
- ("state", "=", "done"),
- ("date", ">", date),
- ("location_dest_id.usage", "=", "customer"),
- ("location_id.usage", "!=", "customer"),
- ("product_id.type", "=", "product"),
- "|",
- ("product_id.abc_classification_profile_id", "=", self.id),
- "|",
- ("product_id.categ_id.abc_classification_profile_id", "=", self.id),
- (
- "product_id.categ_id.parent_id.abc_classification_profile_id",
- "=",
- self.id,
- ),
- ],
- ["product_id", "product_qty"],
- ["product_id"],
- )
- )
- for move in moves:
- product_data = {
- "product": self.env["product.product"].browse(move["product_id"][0]),
- "units_sold": move["product_qty"],
- }
- product_list.append(product_data)
- return product_list
-
- def _get_inventory_product_value(self, data):
- self.ensure_one()
- if self.value_criteria == "consumption_value":
- return data["unit_cost"] * data["units_sold"]
- raise 0.0
-
- @api.model
- def _compute_abc_classification(self):
- def _get_sort_key_value(data):
- return data["value"]
-
- def _get_sort_key_percentage(rec):
- return rec.percentage
-
- profiles = self.search([]).filtered(lambda p: p.level_ids)
- for profile in profiles:
- oldest_date = fields.Datetime.to_string(
- fields.Datetime.today() - timedelta(days=profile.past_period)
- )
- totals = {
- "units_sold": 0,
- "value": 0.0,
- }
- product_list = profile._fill_initial_product_data(oldest_date)
- for product_data in product_list:
- product_data["unit_cost"] = product_data["product"].standard_price
- totals["units_sold"] += product_data["units_sold"]
- product_data["value"] = profile._get_inventory_product_value(
- product_data
- )
- totals["value"] += product_data["value"]
- product_list.sort(reverse=True, key=_get_sort_key_value)
- levels = profile.level_ids.sorted(
- key=_get_sort_key_percentage, reverse=True
- )
- percentages = levels.mapped("percentage")
- level_percentage = list(zip(levels, percentages))
- for product_data in product_list:
- product_data["value_percentage"] = (
- (100.0 * product_data["value"] / totals["value"])
- if totals["value"]
- else 0.0
- )
- while (
- product_data["value_percentage"] < level_percentage[0][1]
- and len(level_percentage) > 1
- ):
- level_percentage.pop(0)
- product_data["product"].abc_classification_level_id = level_percentage[
- 0
- ][0]
diff --git a/product_abc_classification/models/product_category.py b/product_abc_classification/models/product_category.py
deleted file mode 100644
index df7348e554c..00000000000
--- a/product_abc_classification/models/product_category.py
+++ /dev/null
@@ -1,26 +0,0 @@
-# Copyright 2020 ForgeFlow
-# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
-
-from odoo import api, fields, models
-
-
-class ProductCategory(models.Model):
- _inherit = "product.category"
-
- abc_classification_profile_id = fields.Many2one("abc.classification.profile")
- product_variant_ids = fields.One2many("product.product", inverse_name="categ_id")
-
- @api.onchange("abc_classification_profile_id")
- def _onchange_abc_classification_profile_id(self):
- for categ in self:
- for child in categ._origin.child_id:
- child.abc_classification_profile_id = (
- categ.abc_classification_profile_id
- )
- child._onchange_abc_classification_profile_id()
- for variant in categ._origin.product_variant_ids.filtered(
- lambda p: p.type == "product"
- ):
- variant.abc_classification_profile_id = (
- categ.abc_classification_profile_id
- )
diff --git a/product_abc_classification/models/product_product.py b/product_abc_classification/models/product_product.py
deleted file mode 100644
index 99ce9470481..00000000000
--- a/product_abc_classification/models/product_product.py
+++ /dev/null
@@ -1,74 +0,0 @@
-# Copyright 2020 ForgeFlow
-# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html).
-
-from odoo import api, fields, models
-
-
-class ProductTemplate(models.Model):
- _inherit = "product.template"
-
- abc_classification_profile_id = fields.Many2one(
- "abc.classification.profile",
- compute="_compute_abc_classification_profile_id",
- inverse="_inverse_abc_classification_profile_id",
- store=True,
- )
- abc_classification_level_id = fields.Many2one(
- "abc.classification.profile.level",
- compute="_compute_abc_classification_level_id",
- inverse="_inverse_abc_classification_level_id",
- store=True,
- )
-
- @api.depends(
- "product_variant_ids", "product_variant_ids.abc_classification_profile_id"
- )
- def _compute_abc_classification_profile_id(self):
- unique_variants = self.filtered(
- lambda template: len(template.product_variant_ids) == 1
- )
- for template in unique_variants:
- template.abc_classification_profile_id = (
- template.product_variant_ids.abc_classification_profile_id
- )
- for template in self - unique_variants:
- template.abc_classification_profile_id = False
-
- @api.depends(
- "product_variant_ids", "product_variant_ids.abc_classification_level_id"
- )
- def _compute_abc_classification_level_id(self):
- unique_variants = self.filtered(
- lambda template: len(template.product_variant_ids) == 1
- )
- for template in unique_variants:
- template.abc_classification_level_id = (
- template.product_variant_ids.abc_classification_level_id
- )
- for template in self - unique_variants:
- template.abc_classification_level_id = False
-
- def _inverse_abc_classification_profile_id(self):
- for template in self:
- if len(template.product_variant_ids) == 1:
- template.product_variant_ids.abc_classification_profile_id = (
- template.abc_classification_profile_id
- )
-
- def _inverse_abc_classification_level_id(self):
- for template in self:
- if len(template.product_variant_ids) == 1:
- template.product_variant_ids.abc_classification_level_id = (
- template.abc_classification_level_id
- )
-
-
-class ProductProduct(models.Model):
- _inherit = "product.product"
-
- abc_classification_profile_id = fields.Many2one(
- "abc.classification.profile", index=True
- )
- abc_classification_level_id = fields.Many2one(
- "abc.classification.profile.level", index=True
- )
diff --git a/product_abc_classification/readme/CONTRIBUTORS.rst b/product_abc_classification/readme/CONTRIBUTORS.rst
deleted file mode 100644
index 2e34e218a54..00000000000
--- a/product_abc_classification/readme/CONTRIBUTORS.rst
+++ /dev/null
@@ -1 +0,0 @@
-* Miquel Raïch
diff --git a/product_abc_classification/readme/DESCRIPTION.rst b/product_abc_classification/readme/DESCRIPTION.rst
deleted file mode 100644
index b55e108765f..00000000000
--- a/product_abc_classification/readme/DESCRIPTION.rst
+++ /dev/null
@@ -1,8 +0,0 @@
-This modules includes the ABC analysis (or ABC classification), which is
-used by inventory management teams to help identify the most important
-products in their portfolio and ensure they prioritize managing them above
-those less valuable.
-
-Managers will create a profile with several levels (percentages) and then the
-profiled products will automatically get a corresponding level using the
-ABC classification.
diff --git a/product_abc_classification/readme/USAGE.rst b/product_abc_classification/readme/USAGE.rst
deleted file mode 100644
index a66526e486c..00000000000
--- a/product_abc_classification/readme/USAGE.rst
+++ /dev/null
@@ -1,11 +0,0 @@
-To use this module, you need to:
-
-#. Go to Sales or Inventory menu, then to Configuration/Products/ABC Classification Profile
-and create a profile with levels, knowing that the sum of all levels in the profile
-should sum 100 and all the levels should be different.
-
-#. Later you should go to product categories or product variants, and assign them a profile.
-Then the cron classification will proceed to assign to these products one of the profile's levels.
-
-NOTE: If you profile (or unprofile) a product category, then all its
-child categories and products will be profiled (or unprofiled).
diff --git a/product_abc_classification/security/ir.model.access.csv b/product_abc_classification/security/ir.model.access.csv
deleted file mode 100644
index 421beb328a9..00000000000
--- a/product_abc_classification/security/ir.model.access.csv
+++ /dev/null
@@ -1,5 +0,0 @@
-id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
-access_abc_classification_profile_user,abc.classification.profile.user,model_abc_classification_profile,base.group_user,1,0,0,0
-access_abc_classification_profile_manager,abc.classification.profile.manager,model_abc_classification_profile,base.group_system,1,1,1,1
-access_abc_classification_profile_level_user,abc.classification.profile.level.user,model_abc_classification_profile_level,base.group_user,1,0,0,0
-access_abc_classification_profile_level_manager,abc.classification.profile.level.manager,model_abc_classification_profile_level,base.group_system,1,1,1,1
diff --git a/product_abc_classification/static/description/icon.png b/product_abc_classification/static/description/icon.png
deleted file mode 100644
index 3a0328b516c4980e8e44cdb63fd945757ddd132d..0000000000000000000000000000000000000000
GIT binary patch
literal 0
HcmV?d00001
literal 9455
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
z
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
Xviia!U7SGha1wx#SCgwmn*{w2TRX*I
diff --git a/product_abc_classification/views/abc_classification_view.xml b/product_abc_classification/views/abc_classification_view.xml
deleted file mode 100644
index 001fd3b21e6..00000000000
--- a/product_abc_classification/views/abc_classification_view.xml
+++ /dev/null
@@ -1,69 +0,0 @@
-
-
-
-
- abc.classification.profile.form
- abc.classification.profile
-
-
-
-
-
- abc.classification.profile.tree
- abc.classification.profile
-
-
-
-
-
-
-
-
- ABC Classification Profile
- abc.classification.profile
- tree,form
-
-
- Click to add a new profile.
-
-
- This allows to create an ABC classification.
-
-
-
-
-
-
diff --git a/product_abc_classification/views/product_view.xml b/product_abc_classification/views/product_view.xml
deleted file mode 100644
index 5caf6a28283..00000000000
--- a/product_abc_classification/views/product_view.xml
+++ /dev/null
@@ -1,94 +0,0 @@
-
-
-
-
-
- product.template.search (ABC Classification)
- product.template
-
-
-
-
-
-
-
-
-
- product.template.tree
- product.template
-
-
-
-
-
-
-
-
-
- product.product.tree
- product.product
-
-
-
-
-
-
-
-
-
- product.template.form (ABC Classification)
- product.template
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- product.category.form (ABC Classification)
- product.category
-
-
-
-
-
-
-
-
From 0cfc4b5450b237e719515f7d1899e6129968dfc4 Mon Sep 17 00:00:00 2001
From: Denis Roussel
Date: Thu, 10 Nov 2022 14:21:24 +0100
Subject: [PATCH 03/22] [IMP] product_abc_classification/base: Restore module
name
---
product_abc_classification/README.rst | 102 ++++
product_abc_classification/__init__.py | 1 +
product_abc_classification/__manifest__.py | 22 +
product_abc_classification/data/ir_cron.xml | 14 +
product_abc_classification/i18n/fr.po | 327 +++++++++++++
product_abc_classification/models/__init__.py | 5 +
.../models/abc_classification_level.py | 48 ++
.../abc_classification_product_level.py | 159 +++++++
.../models/abc_classification_profile.py | 96 ++++
.../models/product_product.py | 21 +
.../models/product_template.py | 70 +++
.../readme/CONTRIBUTORS.rst | 4 +
.../readme/DESCRIPTION.rst | 11 +
product_abc_classification/readme/USAGE.rst | 11 +
.../security/ir.model.access.csv | 7 +
.../static/description/icon.png | Bin 0 -> 9455 bytes
.../static/description/index.html | 443 ++++++++++++++++++
product_abc_classification/tests/__init__.py | 3 +
product_abc_classification/tests/common.py | 123 +++++
.../test_abc_classification_product_level.py | 363 ++++++++++++++
.../tests/test_abc_classification_profile.py | 300 ++++++++++++
.../tests/test_product.py | 109 +++++
.../abc_classification_product_level.xml | 101 ++++
.../views/abc_classification_profile.xml | 54 +++
.../views/product_product.xml | 19 +
.../views/product_template.xml | 32 ++
26 files changed, 2445 insertions(+)
create mode 100644 product_abc_classification/README.rst
create mode 100644 product_abc_classification/__init__.py
create mode 100644 product_abc_classification/__manifest__.py
create mode 100644 product_abc_classification/data/ir_cron.xml
create mode 100644 product_abc_classification/i18n/fr.po
create mode 100644 product_abc_classification/models/__init__.py
create mode 100644 product_abc_classification/models/abc_classification_level.py
create mode 100644 product_abc_classification/models/abc_classification_product_level.py
create mode 100644 product_abc_classification/models/abc_classification_profile.py
create mode 100644 product_abc_classification/models/product_product.py
create mode 100644 product_abc_classification/models/product_template.py
create mode 100644 product_abc_classification/readme/CONTRIBUTORS.rst
create mode 100644 product_abc_classification/readme/DESCRIPTION.rst
create mode 100644 product_abc_classification/readme/USAGE.rst
create mode 100644 product_abc_classification/security/ir.model.access.csv
create mode 100644 product_abc_classification/static/description/icon.png
create mode 100644 product_abc_classification/static/description/index.html
create mode 100644 product_abc_classification/tests/__init__.py
create mode 100644 product_abc_classification/tests/common.py
create mode 100644 product_abc_classification/tests/test_abc_classification_product_level.py
create mode 100644 product_abc_classification/tests/test_abc_classification_profile.py
create mode 100644 product_abc_classification/tests/test_product.py
create mode 100644 product_abc_classification/views/abc_classification_product_level.xml
create mode 100644 product_abc_classification/views/abc_classification_profile.xml
create mode 100644 product_abc_classification/views/product_product.xml
create mode 100644 product_abc_classification/views/product_template.xml
diff --git a/product_abc_classification/README.rst b/product_abc_classification/README.rst
new file mode 100644
index 00000000000..bbed589ebf5
--- /dev/null
+++ b/product_abc_classification/README.rst
@@ -0,0 +1,102 @@
+==========================
+Product Abc Classification
+==========================
+
+.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+ !! This file is generated by oca-gen-addon-readme !!
+ !! changes will be overwritten. !!
+ !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
+
+.. |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/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
+ :target: https://github.com/OCA/product-attribute/tree/16.0/product_abc_classification_base
+ :alt: OCA/product-attribute
+.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png
+ :target: https://translation.odoo-community.org/projects/product-attribute-16-0/product-attribute-16-0-product_abc_classification_base
+ :alt: Translate me on Weblate
+.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png
+ :target: https://runbot.odoo-community.org/runbot/135/16.0
+ :alt: Try me on Runbot
+
+|badge1| |badge2| |badge3| |badge4| |badge5|
+
+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 portfolio and ensure they
+prioritize managing them above those less valuable.
+
+Managers will create a profile with several levels (percentages) and then the
+profiled products will automatically get a corresponding level using the
+ABC classification.
+
+The addon *product_abc_classification_sale_stock* defines a computation profile
+based on the number of sale order line delivered by product.
+
+**Table of contents**
+
+.. contents::
+ :local:
+
+Usage
+=====
+
+To use this module, you need to:
+
+#. Go to Sales or Inventory menu, then to Configuration/Products/ABC Classification Profile
+and create a profile with levels, knowing that the sum of all levels in the profile
+should sum 100 and all the levels should be different.
+
+#. Later you should go to product categories or product variants, and assign them a profile.
+Then the cron classification will proceed to assign to these products one of the profile's levels.
+
+NOTE: If you profile (or unprofile) a product category, then all its
+child categories and products will be profiled (or unprofiled).
+
+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 smashing it by providing a detailed and welcomed
+`feedback `_.
+
+Do not contact contributors directly about support or help with technical issues.
+
+Credits
+=======
+
+Authors
+~~~~~~~
+
+* ACSONE SA/NV
+* ForgeFlow
+
+Contributors
+~~~~~~~~~~~~
+
+* Miquel Raïch
+* Lindsay Marion
+* Laurent Mignon
+* Denis Roussel
+
+Maintainers
+~~~~~~~~~~~
+
+This module is maintained by the OCA.
+
+.. image:: https://odoo-community.org/logo.png
+ :alt: Odoo Community Association
+ :target: https://odoo-community.org
+
+OCA, or the Odoo Community Association, is a nonprofit organization whose
+mission is to support the collaborative development of Odoo features and
+promote its widespread use.
+
+This module is part of the `OCA/product-attribute `_ project on GitHub.
+
+You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
diff --git a/product_abc_classification/__init__.py b/product_abc_classification/__init__.py
new file mode 100644
index 00000000000..0650744f6bc
--- /dev/null
+++ b/product_abc_classification/__init__.py
@@ -0,0 +1 @@
+from . import models
diff --git a/product_abc_classification/__manifest__.py b/product_abc_classification/__manifest__.py
new file mode 100644
index 00000000000..310fe0ad734
--- /dev/null
+++ b/product_abc_classification/__manifest__.py
@@ -0,0 +1,22 @@
+# Copyright 2020 ForgeFlow
+# Copyright 2021 ACSONE SA/NV
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+{
+ "name": "Product Abc Classification",
+ "summary": """
+ ABC classification for sales and warehouse management""",
+ "version": "16.0.1.0.0",
+ "license": "AGPL-3",
+ "author": "ACSONE SA/NV, ForgeFlow, Odoo Community Association (OCA)",
+ "website": "https://github.com/OCA/product-attribute",
+ "depends": ["product", "stock"],
+ "data": [
+ "views/abc_classification_product_level.xml",
+ "views/abc_classification_profile.xml",
+ "views/product_template.xml",
+ "views/product_product.xml",
+ "security/ir.model.access.csv",
+ "data/ir_cron.xml",
+ ],
+}
diff --git a/product_abc_classification/data/ir_cron.xml b/product_abc_classification/data/ir_cron.xml
new file mode 100644
index 00000000000..04303328c19
--- /dev/null
+++ b/product_abc_classification/data/ir_cron.xml
@@ -0,0 +1,14 @@
+
+
+
+ Perform the product ABC Classification
+
+ 1
+ months
+ -1
+
+
+ model._cron_compute_abc_classification()
+ code
+
+
diff --git a/product_abc_classification/i18n/fr.po b/product_abc_classification/i18n/fr.po
new file mode 100644
index 00000000000..47bc99abc3f
--- /dev/null
+++ b/product_abc_classification/i18n/fr.po
@@ -0,0 +1,327 @@
+# Translation of Odoo Server.
+# This file contains the translation of the following modules:
+# * product_abc_classification
+#
+msgid ""
+msgstr ""
+"Project-Id-Version: Odoo Server 10.0+e\n"
+"Report-Msgid-Bugs-To: \n"
+"POT-Creation-Date: 2021-02-15 16:46+0000\n"
+"PO-Revision-Date: 2021-02-15 16:46+0000\n"
+"Last-Translator: <>\n"
+"Language-Team: \n"
+"MIME-Version: 1.0\n"
+"Content-Type: text/plain; charset=UTF-8\n"
+"Content-Transfer-Encoding: \n"
+"Plural-Forms: \n"
+
+#. module: product_abc_classification
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_level_percentage
+msgid "% Indicator"
+msgstr "% KPI
+
+#. module: product_abc_classification
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_level_percentage_products
+msgid "% Products"
+msgstr "% Articles"
+
+#. module: product_abc_classification
+#: model:ir.ui.view,arch_db:product_abc_classification.product_template_form_view
+msgid "ABC Classification"
+msgstr "Classification ABC"
+
+#. module: product_abc_classification
+#: model:ir.ui.view,arch_db:product_abc_classification.abc_classification_product_level_form_view
+msgid "ABC Classification Product Level"
+msgstr "Niveau de classification ABC des articles"
+
+#. module: product_abc_classification
+#: model:ir.actions.act_window,name:product_abc_classification.abc_classification_profile_action
+#: model:ir.ui.menu,name:product_abc_classification.menu_abc_classification_profile_config_stock
+msgid "ABC Classification profiles"
+msgstr "Profils de classification ABC"
+
+#. module: product_abc_classification
+#: model:ir.ui.view,arch_db:product_abc_classification.abc_classification_profile_form_view
+msgid "ABC Profile"
+msgstr "Profil ABC"
+
+#. module: product_abc_classification
+#: model:ir.ui.view,arch_db:product_abc_classification.abc_classification_profile_tree_view
+msgid "ABC Profiles"
+msgstr "Profils ABC"
+
+#. module: product_abc_classification
+#: model:ir.model,name:product_abc_classification.model_abc_classification_product_level
+msgid "Abc Classification Product Level"
+msgstr "Niveau de classification"
+
+#. module: product_abc_classification
+#: model:ir.model,name:product_abc_classification.model_abc_classification_profile
+msgid "Abc Classification Profile"
+msgstr "Profil de classification ABC"
+
+#. module: product_abc_classification
+#: model:ir.ui.view,arch_db:product_abc_classification.abc_classification_product_level_search_view
+msgid "Abc classification"
+msgstr "Classification ABC"
+
+#. module: product_abc_classification
+#: model:ir.model.fields,field_description:product_abc_classification.field_delivery_carrier_abc_classification_product_level_ids
+#: model:ir.model.fields,field_description:product_abc_classification.field_product_product_abc_classification_product_level_ids
+#: model:ir.model.fields,field_description:product_abc_classification.field_product_template_abc_classification_product_level_ids
+msgid "Abc classification product level ids"
+msgstr "Classes ABC"
+
+#. module: product_abc_classification
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_product_level_allowed_profile_ids
+#: model:ir.model.fields,field_description:product_abc_classification.field_delivery_carrier_abc_classification_profile_ids
+#: model:ir.model.fields,field_description:product_abc_classification.field_product_product_abc_classification_profile_ids
+#: model:ir.model.fields,field_description:product_abc_classification.field_product_template_abc_classification_profile_ids
+msgid "Abc classification profile ids"
+msgstr "Profils ABC"
+
+#. module: product_abc_classification
+#: selection:abc.classification.profile,profile_type:0
+msgid "Based on the count of delivered sale order line by product"
+msgstr "Basé sur le total des lignes de vente par article"
+
+#. module: product_abc_classification
+#: model:ir.model.fields,help:product_abc_classification.field_abc_classification_level_name
+msgid "Classification A, B or C"
+msgstr "Classification A, B ou C"
+
+#. module: product_abc_classification
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_product_level_level_id
+msgid "Classification level"
+msgstr "Classe / Niveau"
+
+#. module: product_abc_classification
+#: code:addons/product_abc_classification/models/abc_classification_product_level.py:84
+#, python-format
+msgid "Classification level is mandatory"
+msgstr "La classe / niveau est obligatoire"
+
+#. module: product_abc_classification
+#: model:ir.ui.view,arch_db:product_abc_classification.abc_classification_product_level_search_view
+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_computed_level_id
+msgid "Computed classification level"
+msgstr "Classe calculée"
+
+#. module: product_abc_classification
+#: model:ir.ui.view,arch_db:product_abc_classification.abc_classification_product_level_form_view
+msgid "Computed level differs from the specified level"
+msgstr "La class calculée diverge de la valeur spécifiée"
+
+#. module: product_abc_classification
+#: code:addons/product_abc_classification/models/abc_classification_product_level.py:90
+#, python-format
+msgid "Computed level must be in the same classifiation profile as the one on the product level"
+msgstr "La classe calculée doit utiliser le même profil de classification que celui défini sur le produit"
+
+#. module: product_abc_classification
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_level_create_uid
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_product_level_create_uid
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_profile_create_uid
+msgid "Created by"
+msgstr "Créé par"
+
+#. module: product_abc_classification
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_level_create_date
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_product_level_create_date
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_profile_create_date
+msgid "Created on"
+msgstr "Créé le"
+
+#. module: product_abc_classification
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_level_display_name
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_product_level_display_name
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_profile_display_name
+msgid "Display Name"
+msgstr "Nom affiché"
+
+#. module: product_abc_classification
+#: model:ir.ui.view,arch_db:product_abc_classification.abc_classification_product_level_search_view
+msgid "Group By"
+msgstr "Grouper par"
+
+#. module: product_abc_classification
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_level_id
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_product_level_id
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_profile_id
+msgid "ID"
+msgstr "ID"
+
+#. module: product_abc_classification
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_product_level_flag
+msgid "If True, this means that the manual classification is different from the computed one"
+msgstr "Si coché, indique que la classe attribuée manuellement au produit diverge de la classe calculée par le système."
+
+#. module: product_abc_classification
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_level___last_update
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_product_level___last_update
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_profile___last_update
+msgid "Last Modified on"
+msgstr "Dernière modification le"
+
+#. module: product_abc_classification
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_level_write_uid
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_product_level_write_uid
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_profile_write_uid
+msgid "Last Updated by"
+msgstr "Dernière mise à jour par"
+
+#. module: product_abc_classification
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_level_write_date
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_product_level_write_date
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_profile_write_date
+msgid "Last Updated on"
+msgstr "Dernière mise à jour le"
+
+#. module: product_abc_classification
+#: model:ir.ui.view,arch_db:product_abc_classification.abc_classification_product_level_search_view
+msgid "Level"
+msgstr "Niveau"
+
+#. module: product_abc_classification
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_profile_level_ids
+msgid "Level ids"
+msgstr "Classes"
+
+#. module: product_abc_classification
+#: sql_constraint:abc.classification.level:0
+#: code:addons/product_abc_classification/models/abc_classification_level.py:30
+#, python-format
+msgid "Level name must be unique by profile"
+msgstr "Le nom de la classe doit être unique par profil"
+
+#. module: product_abc_classification
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_product_level_manual_level_id
+msgid "Manual classification level"
+msgstr "Classe (Valeur à utiliser)"
+
+#. module: product_abc_classification
+#: code:addons/product_abc_classification/models/abc_classification_product_level.py:100
+#, python-format
+msgid "Manual level must be in the same classifiation profile as the one on the product level"
+msgstr "La classe à utiliser doit utiliser le même profil de classification que celui défini sur le produit"
+
+#. module: product_abc_classification
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_level_name
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_profile_name
+msgid "Name"
+msgstr "Nom"
+
+#. module: product_abc_classification
+#: sql_constraint:abc.classification.product.level:0
+#: code:addons/product_abc_classification/models/abc_classification_product_level.py:76
+#, python-format
+msgid "Only one level by profile by product allowed"
+msgstr "Une classe de classification ABC par profil et par produit autorisée."
+
+#. module: product_abc_classification
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_profile_period
+msgid "Period on which to compute the classification (Days)"
+msgstr "Période référence pour le calcul de la classification (Nbr jours)"
+
+#. module: product_abc_classification
+#: model:ir.model,name:product_abc_classification.model_product_product
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_product_level_product_id
+msgid "Product"
+msgstr "Article"
+
+#. module: product_abc_classification
+#: model:ir.model,name:product_abc_classification.model_product_template
+msgid "Product Template"
+msgstr "Modèle de produit"
+
+#. module: product_abc_classification
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_product_level_product_tmpl_id
+msgid "Product template"
+msgstr "Modèle de produit"
+
+#. module: product_abc_classification
+#: model:ir.actions.act_window,name:product_abc_classification.abc_classification_product_level_action
+#: model:ir.ui.menu,name:product_abc_classification.menu_abc_classification_product_level_config_stock
+msgid "Products ABC Classification"
+msgstr "Classification ABC des articles"
+
+#. module: product_abc_classification
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_product_level_profile_id
+#: model:ir.ui.view,arch_db:product_abc_classification.abc_classification_product_level_search_view
+msgid "Profile"
+msgstr "Profil"
+
+#. module: product_abc_classification
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_level_profile_id
+msgid "Profile id"
+msgstr "Profil"
+
+#. module: product_abc_classification
+#: sql_constraint:abc.classification.profile:0
+#: code:addons/product_abc_classification/models/abc_classification_profile.py:33
+#, python-format
+msgid "Profile name must be unique"
+msgstr "Le nom du profil doit être unique"
+
+#. module: product_abc_classification
+#: code:addons/product_abc_classification/models/abc_classification_level.py:39
+#, python-format
+msgid "The percentage cannot be greater than 100."
+msgstr "Le pourcentage ne peut pas dépasser 100."
+
+#. module: product_abc_classification
+#: code:addons/product_abc_classification/models/abc_classification_level.py:51
+#, python-format
+msgid "The percentage of products cannot be greater than 100."
+msgstr "Le pourcentage d'articles' ne peut pas dépasser 100."
+
+#. module: product_abc_classification
+#: code:addons/product_abc_classification/models/abc_classification_level.py:55
+#, python-format
+msgid "The percentage of products should be a positive number."
+msgstr "Le pourcentage d'articles' doit être un nombre positif."
+
+#. module: product_abc_classification
+#: code:addons/product_abc_classification/models/abc_classification_level.py:43
+#, python-format
+msgid "The percentage should be a positive number."
+msgstr "Le pourcentage doit être un nombre positif."
+
+#. module: product_abc_classification
+#: code:addons/product_abc_classification/models/abc_classification_profile.py:52
+#, python-format
+msgid "The percentages of the levels must be unique."
+msgstr "Les valeurs de pourcentage des différentes classes doivent être uniques pour un même profil."
+
+#. module: product_abc_classification
+#: code:addons/product_abc_classification/models/abc_classification_profile.py:43
+#, python-format
+msgid "The sum of the percentages of the levels should be 100."
+msgstr "La somme des pourcentages ne doit pas dépasser 100."
+
+#. module: product_abc_classification
+#: code:addons/product_abc_classification/models/abc_classification_profile.py:60
+#, python-format
+msgid "The sum of the products percentages of the levels should be 100."
+msgstr "La somme des pourcentages d'articles ne doit pas dépasser 100."
+
+#. module: product_abc_classification
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_product_level_profile_type
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_profile_profile_type
+msgid "Type of ABC classification"
+msgstr "Type de classification ABC"
+
+#. module: product_abc_classification
+#: model:ir.model.fields,field_description:product_abc_classification.field_abc_classification_profile_auto_apply_computed_value
+msgid "Auto apply computed value"
+msgstr "Appliquer automatiquement la classification calculée"
+
+#. module: product_abc_classification
+#: model:ir.model,name:product_abc_classification.model_abc_classification_level
+msgid "abc.classification.level"
+msgstr "Classe de classification ABC"
diff --git a/product_abc_classification/models/__init__.py b/product_abc_classification/models/__init__.py
new file mode 100644
index 00000000000..b98adc64bd4
--- /dev/null
+++ b/product_abc_classification/models/__init__.py
@@ -0,0 +1,5 @@
+from . import abc_classification_profile
+from . import abc_classification_level
+from . import product_template
+from . import product_product
+from . import abc_classification_product_level
diff --git a/product_abc_classification/models/abc_classification_level.py b/product_abc_classification/models/abc_classification_level.py
new file mode 100644
index 00000000000..01b045eabc7
--- /dev/null
+++ b/product_abc_classification/models/abc_classification_level.py
@@ -0,0 +1,48 @@
+# Copyright 2020 ForgeFlow
+# Copyright 2021 ACSONE SA/NV
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError
+
+
+class AbcClassificationLevel(models.Model):
+
+ _name = "abc.classification.level"
+ _description = "ABC Classification Level"
+ _order = "percentage desc, id desc"
+ _rec_name = "name"
+
+ percentage_products = fields.Float(default=0.0, required=True, string="% Products")
+ percentage = fields.Float(default=0.0, required=True, string="% Indicator")
+ profile_id = fields.Many2one("abc.classification.profile", ondelete="cascade")
+
+ name = fields.Char(help="Classification A, B or C", required=True)
+
+ _sql_constraints = [
+ (
+ "name_uniq",
+ "UNIQUE(profile_id, name)",
+ _("Level name must be unique by profile"),
+ )
+ ]
+
+ @api.constrains("percentage")
+ def _check_percentage(self):
+ for level in self:
+ if level.percentage > 100.0:
+ raise ValidationError(_("The percentage cannot be greater than 100."))
+ if level.percentage <= 0.0:
+ raise ValidationError(_("The percentage should be a positive number."))
+
+ @api.constrains("percentage_products")
+ def _check_percentage_products(self):
+ for level in self:
+ if level.percentage_products > 100.0:
+ raise ValidationError(
+ _("The percentage of products cannot be greater than 100.")
+ )
+ if level.percentage_products <= 0.0:
+ raise ValidationError(
+ _("The percentage of products should be a positive number.")
+ )
diff --git a/product_abc_classification/models/abc_classification_product_level.py b/product_abc_classification/models/abc_classification_product_level.py
new file mode 100644
index 00000000000..1ad9cf6c184
--- /dev/null
+++ b/product_abc_classification/models/abc_classification_product_level.py
@@ -0,0 +1,159 @@
+# Copyright 2021 ACSONE SA/NV
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError
+
+
+class AbcClassificationProductLevel(models.Model):
+ _name = "abc.classification.product.level"
+ _inherit = "mail.thread"
+ _description = "Abc Classification Product Level"
+ _rec_name = "level_id"
+
+ display_name = fields.Char(compute="_compute_display_name")
+
+ manual_level_id = fields.Many2one(
+ "abc.classification.level",
+ string="Manual classification level",
+ tracking=True,
+ domain="[('profile_id', '=', profile_id)]",
+ )
+ computed_level_id = fields.Many2one(
+ "abc.classification.level",
+ string="Computed classification level",
+ readonly=True,
+ )
+ level_id = fields.Many2one(
+ "abc.classification.level",
+ string="Classification level",
+ compute="_compute_level_id",
+ store=True,
+ domain="[('profile_id', '=', profile_id)]",
+ )
+ flag = fields.Boolean(
+ default=False,
+ compute="_compute_flag",
+ string="If True, this means that the manual classification is "
+ "different from the computed one",
+ store=True,
+ index=True,
+ )
+ product_id = fields.Many2one(
+ "product.product",
+ string="Product",
+ index=True,
+ required=True,
+ ondelete="cascade",
+ )
+ product_tmpl_id = fields.Many2one(
+ "product.template",
+ string="Product template",
+ index=True,
+ readonly=True,
+ )
+ # percentage
+ profile_id = fields.Many2one(
+ "abc.classification.profile",
+ string="Profile",
+ required=True,
+ )
+ profile_type = fields.Selection(
+ related="profile_id.profile_type",
+ readonly=True,
+ store=True,
+ )
+ allowed_profile_ids = fields.Many2many(
+ comodel_name="abc.classification.profile",
+ related="product_id.abc_classification_profile_ids",
+ )
+
+ _sql_constraints = [
+ (
+ "product_level_uniq",
+ "UNIQUE(profile_id, product_id)",
+ _("Only one level by profile by product allowed"),
+ )
+ ]
+
+ @api.constrains("computed_level_id", "manual_level_id", "product_id")
+ def _check_level(self):
+ for rec in self:
+ if not rec.computed_level_id and not rec.manual_level_id:
+ raise ValidationError(_("Classification level is mandatory"))
+ if (
+ rec.computed_level_id
+ and rec.computed_level_id.profile_id != rec.profile_id
+ ):
+ raise ValidationError(
+ _(
+ "Computed level must be in the same classifiation "
+ "profile as the one on the product level"
+ )
+ )
+ if rec.manual_level_id and rec.manual_level_id.profile_id != rec.profile_id:
+ raise ValidationError(
+ _(
+ "Manual level must be in the same classifiation "
+ "profile as the one on the product level"
+ )
+ )
+
+ @api.onchange("product_tmpl_id")
+ def _onchange_product_tmpl_id(self):
+ for rec in self.filtered(
+ lambda a: a.product_tmpl_id.product_variant_count == 1
+ ):
+ rec.product_id = rec.product_tmpl_id.product_variant_id
+
+ @api.depends("level_id", "profile_id")
+ def _compute_display_name(self):
+ for record in self:
+ record.display_name = "{profile_name}: {level_name}".format(
+ profile_name=record.profile_id.name,
+ level_name=record.level_id.name,
+ )
+
+ @api.depends("manual_level_id", "computed_level_id")
+ def _compute_level_id(self):
+ for rec in self:
+ if rec.manual_level_id:
+ rec.level_id = rec.manual_level_id
+ else:
+ rec.level_id = rec.computed_level_id
+
+ @api.depends("manual_level_id", "computed_level_id")
+ def _compute_flag(self):
+ for rec in self:
+ rec.flag = (
+ rec.computed_level_id and rec.manual_level_id != rec.computed_level_id
+ )
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ if "manual_level_id" not in vals and "computed_level_id" in vals:
+ # at creation the manual level is set to the same value as the
+ # computed one
+ vals["manual_level_id"] = vals["computed_level_id"]
+
+ if "profile_id" in vals:
+ profile = self.env["abc.classification.profile"].browse(
+ vals["profile_id"]
+ )
+ if profile.auto_apply_computed_value and "computed_level_id" in vals:
+ vals["manual_level_id"] = vals["computed_level_id"]
+ return super().create(vals_list)
+
+ def write(self, vals):
+ values = vals.copy()
+ if "profile_id" in values:
+ profile = self.env["abc.classification.profile"].browse(
+ values["profile_id"]
+ )
+ else:
+ profile = self.mapped("profile_id")
+
+ if profile.auto_apply_computed_value and "computed_level_id" in values:
+ values["manual_level_id"] = values["computed_level_id"]
+ return super().write(values)
diff --git a/product_abc_classification/models/abc_classification_profile.py b/product_abc_classification/models/abc_classification_profile.py
new file mode 100644
index 00000000000..b57b0a1e46d
--- /dev/null
+++ b/product_abc_classification/models/abc_classification_profile.py
@@ -0,0 +1,96 @@
+# Copyright 2020 ForgeFlow
+# Copyright 2021 ACSONE SA/NV
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from psycopg2.extensions import AsIs
+
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError
+
+
+class AbcClassificationProfile(models.Model):
+
+ _name = "abc.classification.profile"
+ _description = "Abc Classification Profile"
+ _rec_name = "name"
+
+ name = fields.Char(required=True)
+ level_ids = fields.One2many(
+ comodel_name="abc.classification.level", inverse_name="profile_id"
+ )
+ profile_type = fields.Selection(
+ selection=[],
+ string="Type of ABC classification",
+ index=True,
+ required=True,
+ )
+ period = fields.Integer(
+ default=365,
+ string="Period on which to compute the classification (Days)",
+ required=True,
+ )
+
+ auto_apply_computed_value = fields.Boolean(default=False)
+
+ _sql_constraints = [("name_uniq", "UNIQUE(name)", _("Profile name must be unique"))]
+
+ @api.constrains("level_ids")
+ def _check_levels(self):
+ for profile in self:
+ percentages = profile.level_ids.mapped("percentage")
+ total = sum(percentages)
+ if profile.level_ids and total != 100.0:
+ raise ValidationError(
+ _("The sum of the percentages of the levels should be " "100.")
+ )
+ if profile.level_ids and len({}.fromkeys(percentages)) != len(percentages):
+ raise ValidationError(
+ _("The percentages of the levels must be unique.")
+ )
+ percentage_productss = profile.level_ids.mapped("percentage_products")
+ total = sum(percentage_productss)
+ if profile.level_ids and total != 100.0:
+ raise ValidationError(
+ _(
+ "The sum of the products percentages of the levels "
+ "should be 100."
+ )
+ )
+
+ def _compute_abc_classification(self):
+ raise NotImplementedError()
+
+ @api.model
+ def _cron_compute_abc_classification(self):
+ self.search([])._compute_abc_classification()
+
+ def write(self, vals):
+ res = super(AbcClassificationProfile, self).write(vals)
+ if "auto_apply_computed_value" in vals and vals["auto_apply_computed_value"]:
+ self._auto_apply_computed_value_for_product_levels()
+ return res
+
+ def _auto_apply_computed_value_for_product_levels(self):
+ level_ids = []
+ for rec in self:
+ self.env.cr.execute(
+ """
+ UPDATE %(table)s
+ SET manual_level_id = computed_level_id
+ WHERE profile_id = %(profile_id)s
+ RETURNING id
+
+ """,
+ {
+ "table": AsIs(self.env["abc.classification.product.level"]._table),
+ "profile_id": rec.id,
+ },
+ )
+ level_ids.extend(r[0] for r in self.env.cr.fetchall())
+ self.env["abc.classification.product.level"].invalidate_cache(
+ ["manual_level_id"], level_ids
+ )
+ modified_levels = self.env["abc.classification.product.level"].browse(level_ids)
+ # mark field as modified and trigger recompute of dependent fields.
+ modified_levels.modified(["manual_level_id"])
+ modified_levels._recompute_recordset()
diff --git a/product_abc_classification/models/product_product.py b/product_abc_classification/models/product_product.py
new file mode 100644
index 00000000000..80f1fc1513f
--- /dev/null
+++ b/product_abc_classification/models/product_product.py
@@ -0,0 +1,21 @@
+# Copyright 2020 ForgeFlow
+# Copyright 2021 ACSONE SA/NV
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from odoo import fields, models
+
+
+class ProductProduct(models.Model):
+
+ _inherit = "product.product"
+
+ abc_classification_product_level_ids = fields.One2many(
+ "abc.classification.product.level", index=True, inverse_name="product_id"
+ )
+ abc_classification_profile_ids = fields.Many2many(
+ comodel_name="abc.classification.profile",
+ relation="abc_classification_profile_product_rel",
+ column1="product_id",
+ column2="profile_id",
+ index=True,
+ )
diff --git a/product_abc_classification/models/product_template.py b/product_abc_classification/models/product_template.py
new file mode 100644
index 00000000000..48c01bbc47d
--- /dev/null
+++ b/product_abc_classification/models/product_template.py
@@ -0,0 +1,70 @@
+# Copyright 2020 ForgeFlow
+# Copyright 2021 ACSONE SA/NV
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from odoo import api, fields, models
+
+
+class ProductTemplate(models.Model):
+ _inherit = "product.template"
+
+ abc_classification_profile_ids = fields.Many2many(
+ "abc.classification.profile",
+ compute="_compute_abc_classification_profile_ids",
+ inverse="_inverse_abc_classification_profile_ids",
+ store=True,
+ )
+ abc_classification_product_level_ids = fields.One2many(
+ "abc.classification.product.level",
+ compute="_compute_abc_classification_product_level_ids",
+ inverse="_inverse_abc_classification_product_level_ids",
+ inverse_name="product_tmpl_id",
+ store=True,
+ )
+
+ @api.depends(
+ "product_variant_ids",
+ "product_variant_ids.abc_classification_profile_ids",
+ )
+ def _compute_abc_classification_profile_ids(self):
+ unique_variants = self.filtered(
+ lambda template: len(template.product_variant_ids) == 1
+ )
+ for template in unique_variants:
+ template.abc_classification_profile_ids = (
+ template.product_variant_ids.abc_classification_profile_ids
+ )
+ for template in self - unique_variants:
+ template.abc_classification_profile_ids = False
+
+ @api.depends(
+ "product_variant_ids",
+ "product_variant_ids.abc_classification_product_level_ids",
+ )
+ def _compute_abc_classification_product_level_ids(self):
+ unique_variants = self.filtered(
+ lambda template: len(template.product_variant_ids) == 1
+ )
+ for template in unique_variants:
+ variants = template.product_variant_ids
+ template.abc_classification_product_level_ids = (
+ variants.abc_classification_product_level_ids
+ )
+ for template in self - unique_variants:
+ template.abc_classification_product_level_ids = False
+
+ def _inverse_abc_classification_profile_ids(self):
+ for template in self:
+ if len(template.product_variant_ids) == 1:
+ variants = template.product_variant_ids
+ variants.abc_classification_profile_ids = (
+ template.abc_classification_profile_ids
+ )
+
+ def _inverse_abc_classification_product_level_ids(self):
+ for template in self:
+ if len(template.product_variant_ids) == 1:
+ variants = template.product_variant_ids
+ variants.abc_classification_product_level_ids = (
+ template.abc_classification_product_level_ids
+ )
diff --git a/product_abc_classification/readme/CONTRIBUTORS.rst b/product_abc_classification/readme/CONTRIBUTORS.rst
new file mode 100644
index 00000000000..fe41e2ce43d
--- /dev/null
+++ b/product_abc_classification/readme/CONTRIBUTORS.rst
@@ -0,0 +1,4 @@
+* Miquel Raïch
+* Lindsay Marion
+* Laurent Mignon
+* Denis Roussel
diff --git a/product_abc_classification/readme/DESCRIPTION.rst b/product_abc_classification/readme/DESCRIPTION.rst
new file mode 100644
index 00000000000..e8bc6d5b704
--- /dev/null
+++ b/product_abc_classification/readme/DESCRIPTION.rst
@@ -0,0 +1,11 @@
+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 portfolio and ensure they
+prioritize managing them above those less valuable.
+
+Managers will create a profile with several levels (percentages) and then the
+profiled products will automatically get a corresponding level using the
+ABC classification.
+
+The addon *product_abc_classification_sale_stock* defines a computation profile
+based on the number of sale order line delivered by product.
diff --git a/product_abc_classification/readme/USAGE.rst b/product_abc_classification/readme/USAGE.rst
new file mode 100644
index 00000000000..a66526e486c
--- /dev/null
+++ b/product_abc_classification/readme/USAGE.rst
@@ -0,0 +1,11 @@
+To use this module, you need to:
+
+#. Go to Sales or Inventory menu, then to Configuration/Products/ABC Classification Profile
+and create a profile with levels, knowing that the sum of all levels in the profile
+should sum 100 and all the levels should be different.
+
+#. Later you should go to product categories or product variants, and assign them a profile.
+Then the cron classification will proceed to assign to these products one of the profile's levels.
+
+NOTE: If you profile (or unprofile) a product category, then all its
+child categories and products will be profiled (or unprofiled).
diff --git a/product_abc_classification/security/ir.model.access.csv b/product_abc_classification/security/ir.model.access.csv
new file mode 100644
index 00000000000..9283b353960
--- /dev/null
+++ b/product_abc_classification/security/ir.model.access.csv
@@ -0,0 +1,7 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_abc_classification_profile_user,abc.classification.profile.user,model_abc_classification_profile,base.group_user,1,0,0,0
+access_abc_classification_profile_manager,abc.classification.profile.manager,model_abc_classification_profile,stock.group_stock_manager,1,1,1,1
+access_abc_classification_level_user,abc.classification.level.user,model_abc_classification_level,base.group_user,1,0,0,0
+access_abc_classification_level_manager,abc.classification.level.manager,model_abc_classification_level,stock.group_stock_manager,1,1,1,1
+access_abc_classification_product_level_user,abc.classification.product.level.user,model_abc_classification_product_level,base.group_user,1,0,0,0
+access_abc_classification_product_level_manager,abc.classification.product.level.manager,model_abc_classification_product_level,stock.group_stock_manager,1,1,0,0
diff --git a/product_abc_classification/static/description/icon.png b/product_abc_classification/static/description/icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..3a0328b516c4980e8e44cdb63fd945757ddd132d
GIT binary patch
literal 9455
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
z
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
Xviia!U7SGha1wx#SCgwmn*{w2TRX*I
literal 0
HcmV?d00001
diff --git a/product_abc_classification/static/description/index.html b/product_abc_classification/static/description/index.html
new file mode 100644
index 00000000000..b309d87953d
--- /dev/null
+++ b/product_abc_classification/static/description/index.html
@@ -0,0 +1,443 @@
+
+
+
+
+
+
+Product Abc Classification
+
+
+
+
+
Product Abc Classification
+
+
+

+
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 portfolio and ensure they
+prioritize managing them above those less valuable.
+
Managers will create a profile with several levels (percentages) and then the
+profiled products will automatically get a corresponding level using the
+ABC classification.
+
The addon product_abc_classification_sale_stock defines a computation profile
+based on the number of sale order line delivered by product.
+
Table of contents
+
+
+
+
To use this module, you need to:
+
#. Go to Sales or Inventory menu, then to Configuration/Products/ABC Classification Profile
+and create a profile with levels, knowing that the sum of all levels in the profile
+should sum 100 and all the levels should be different.
+
#. Later you should go to product categories or product variants, and assign them a profile.
+Then the cron classification will proceed to assign to these products one of the profile’s levels.
+
NOTE: If you profile (or unprofile) a product category, then all its
+child categories and products will be profiled (or unprofiled).
+
+
+
+
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 smashing it by providing a detailed and welcomed
+feedback.
+
Do not contact contributors directly about support or help with technical issues.
+
+
+
+
+
+
+- ACSONE SA/NV
+- ForgeFlow
+
+
+
+
+
+
This module is maintained by the OCA.
+

+
OCA, or the Odoo Community Association, is a nonprofit organization whose
+mission is to support the collaborative development of Odoo features and
+promote its widespread use.
+
This module is part of the OCA/product-attribute project on GitHub.
+
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
+
+
+
+
+
diff --git a/product_abc_classification/tests/__init__.py b/product_abc_classification/tests/__init__.py
new file mode 100644
index 00000000000..8292c06ca32
--- /dev/null
+++ b/product_abc_classification/tests/__init__.py
@@ -0,0 +1,3 @@
+from . import test_abc_classification_product_level
+from . import test_abc_classification_profile
+from . import test_product
diff --git a/product_abc_classification/tests/common.py b/product_abc_classification/tests/common.py
new file mode 100644
index 00000000000..4900770fdce
--- /dev/null
+++ b/product_abc_classification/tests/common.py
@@ -0,0 +1,123 @@
+# Copyright 2021 ACSONE SA/NV
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from odoo.tests.common import TransactionCase
+
+
+class ABCClassificationCase(TransactionCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.env = cls.env(context=dict(cls.env.context, tracking_disable=True))
+ # add a fake profile_type
+ cls.ABCClassificationProfile = cls.env["abc.classification.profile"]
+ cls.ABCClassificationProfile._fields["profile_type"].selection = [
+ ("test_type", "Test Type")
+ ]
+ cls.classification_profile = cls.ABCClassificationProfile.create(
+ {"name": "Profile test", "profile_type": "test_type"}
+ )
+
+
+class ABCClassificationLevelCase(ABCClassificationCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.classification_profile.write(
+ {
+ "level_ids": [
+ (
+ 0,
+ 0,
+ {
+ "percentage": 60,
+ "percentage_products": 40,
+ "name": "a",
+ },
+ ),
+ (
+ 0,
+ 0,
+ {
+ "percentage": 40,
+ "percentage_products": 60,
+ "name": "b",
+ },
+ ),
+ ]
+ }
+ )
+
+ levels = cls.classification_profile.level_ids
+ cls.classification_level_a = levels.filtered(lambda l: l.name == "a")
+ cls.classification_level_b = levels.filtered(lambda l: l.name == "b")
+ cls.classification_profile_bis = cls.ABCClassificationProfile.create(
+ {
+ "name": "Profile test bis",
+ "profile_type": "test_type",
+ "level_ids": [
+ (
+ 0,
+ 0,
+ {
+ "percentage": 80,
+ "percentage_products": 40,
+ "name": "a",
+ },
+ ),
+ (
+ 0,
+ 0,
+ {
+ "percentage": 20,
+ "percentage_products": 60,
+ "name": "b",
+ },
+ ),
+ ],
+ }
+ )
+ levels = cls.classification_profile_bis.level_ids
+ cls.classification_level_bis_a = levels.filtered(lambda l: l.name == "a")
+
+ cls.classification_level_bis_b = levels.filtered(lambda l: l.name == "b")
+ # create a template with one variant adn declare attributes to create
+ # an other variant on demand
+ cls.size_attr = cls.env["product.attribute"].create(
+ {
+ "name": "Size",
+ "create_variant": "no_variant",
+ "value_ids": [(0, 0, {"name": "S"}), (0, 0, {"name": "M"})],
+ }
+ )
+ cls.size_attr_value_s = cls.size_attr.value_ids[0]
+ cls.size_attr_value_m = cls.size_attr.value_ids[1]
+ cls.uom_unit = cls.env.ref("uom.product_uom_unit")
+ cls.product_template = cls.env["product.template"].create(
+ {
+ "name": "Test sized",
+ "uom_id": cls.uom_unit.id,
+ "uom_po_id": cls.uom_unit.id,
+ "attribute_line_ids": [
+ (
+ 0,
+ 0,
+ {
+ "attribute_id": cls.size_attr.id,
+ "value_ids": [(6, 0, cls.size_attr.value_ids.ids)],
+ },
+ )
+ ],
+ }
+ )
+ cls.product_product = cls.product_template.product_variant_ids
+ cls.ProductLevel = cls.env["abc.classification.product.level"]
+
+ @classmethod
+ def _create_variant(cls, size_value):
+ return cls.env["product.product"].create(
+ {
+ "product_tmpl_id": cls.product_template.id,
+ "product_template_attribute_value_ids": [(6, 0, size_value.ids)],
+ }
+ )
diff --git a/product_abc_classification/tests/test_abc_classification_product_level.py b/product_abc_classification/tests/test_abc_classification_product_level.py
new file mode 100644
index 00000000000..9b2db6df872
--- /dev/null
+++ b/product_abc_classification/tests/test_abc_classification_product_level.py
@@ -0,0 +1,363 @@
+# Copyright 2021 ACSONE SA/NV
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from psycopg2 import IntegrityError
+
+from odoo.exceptions import ValidationError
+
+from .common import ABCClassificationLevelCase
+
+
+class TestABCClassificationProductLevel(ABCClassificationLevelCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+ cls.product_1 = cls.env["product.product"].create(
+ {
+ "name": "Test 1",
+ "uom_id": cls.uom_unit.id,
+ "uom_po_id": cls.uom_unit.id,
+ }
+ )
+ cls.product_level = cls.ProductLevel.create(
+ {
+ "product_id": cls.product_product.id,
+ "computed_level_id": cls.classification_level_a.id,
+ "profile_id": cls.classification_profile.id,
+ }
+ )
+
+ @classmethod
+ def _create_product_levels(cls):
+ product_2 = cls.env["product.product"].create(
+ {
+ "name": "Test 2",
+ "uom_id": cls.uom_unit.id,
+ "uom_po_id": cls.uom_unit.id,
+ }
+ )
+
+ product_3 = cls.env["product.product"].create(
+ {
+ "name": "Test 3",
+ "uom_id": cls.uom_unit.id,
+ "uom_po_id": cls.uom_unit.id,
+ }
+ )
+ cls.ProductLevel.create(
+ {
+ "product_id": product_2.id,
+ "manual_level_id": cls.classification_level_b.id,
+ "computed_level_id": cls.classification_level_a.id,
+ "profile_id": cls.classification_profile.id,
+ }
+ )
+ cls.ProductLevel.create(
+ {
+ "product_id": product_3.id,
+ "manual_level_id": cls.classification_level_b.id,
+ "computed_level_id": cls.classification_level_a.id,
+ "profile_id": cls.classification_profile.id,
+ }
+ )
+
+ def test_00(self):
+ """
+ Test case:
+ Create a classification product level with only a computed_level_id
+ Expected result:
+ A instance is created with:
+ * the manual_level_id and level_id set
+ * flag is False since manual and computd are the same
+
+ """
+ level = self.ProductLevel.create(
+ {
+ "product_id": self.product_1.id,
+ "computed_level_id": self.classification_level_a.id,
+ "profile_id": self.classification_profile.id,
+ }
+ )
+ self.assertEqual(level.manual_level_id, self.classification_level_a)
+ self.assertEqual(level.level_id, self.classification_level_a)
+ self.assertFalse(level.flag)
+
+ def test_01(self):
+ """
+ Test case:
+ Create product level with only a manual level
+
+ A creation if a product level is created without computed value
+ the computed value is never taken into account
+ Expected result:
+ A new level is create with:
+ * computed_level_id = False
+ * level_id = manual_level_id
+ * flag = False
+ """
+ level = self.ProductLevel.create(
+ {
+ "product_id": self.product_1.id,
+ "manual_level_id": self.classification_level_a.id,
+ "profile_id": self.classification_profile.id,
+ }
+ )
+ self.assertFalse(level.computed_level_id)
+ self.assertEqual(level.manual_level_id, self.classification_level_a)
+ self.assertEqual(level.level_id, self.classification_level_a)
+ self.assertFalse(level.flag)
+
+ def test_02(self):
+ """
+ Data:
+ An existing classification level with computed = manual
+ Test case:
+ 1. Change manual_level_id to an other value than the computed one
+ 2. Reset manual_level_id to the computed one
+ Expected result:
+ 1. level_id === manual =! computed and flag is true
+ 2 level_id == manual == computed and flag is true
+ ValidationError
+ """
+ self.assertFalse(self.product_level.flag)
+ self.assertEqual(
+ self.product_level.manual_level_id,
+ self.product_level.computed_level_id,
+ )
+ self.assertEqual(
+ self.product_level.computed_level_id, self.classification_level_a
+ )
+ self.assertEqual(self.product_level.level_id, self.classification_level_a)
+ # 1
+ self.product_level.manual_level_id = self.classification_level_b
+ self.assertEqual(self.product_level.level_id, self.classification_level_b)
+ self.assertTrue(self.product_level.flag)
+ # 2
+ self.product_level.manual_level_id = self.product_level.computed_level_id
+ self.assertEqual(self.product_level.level_id, self.classification_level_a)
+ self.assertFalse(self.product_level.flag)
+
+ def test_03(self):
+ """
+ Data:
+ An existing product level
+ Test case:
+ Create a new product level for the same product and the same profile
+ Expected result:
+ IntegrityError (level name must be unique by profile and product)
+ """
+ with self.assertRaises(IntegrityError):
+ self.ProductLevel.create(
+ {
+ "product_id": self.product_product.id,
+ "computed_level_id": self.classification_level_a.id,
+ "profile_id": self.classification_profile.id,
+ }
+ )
+
+ def test_04(self):
+ """
+ Data:
+ An existing product level
+ Test case:
+ 1. Link a manual level from an other profile
+ 2. Link a computed level from an other profile
+ Expected result:
+ 1. and 2. Validation error (All the levels must share the same
+ profile as the one on the product level)
+ """
+ with self.assertRaises(ValidationError), self.env.cr.savepoint():
+ self.product_level.write(
+ {
+ "manual_level_id": self.classification_level_b.id,
+ "computed_level_id": self.classification_level_bis_a.id,
+ }
+ )
+ with self.assertRaises(ValidationError), self.env.cr.savepoint():
+ self.product_level.write(
+ {
+ "manual_level_id": self.classification_level_bis_a.id,
+ "computed_level_id": self.classification_level_a.id,
+ }
+ )
+ self.product_level.write(
+ {
+ "manual_level_id": self.classification_level_bis_a.id,
+ "computed_level_id": self.classification_level_bis_a.id,
+ "profile_id": self.classification_profile_bis.id,
+ }
+ )
+
+ def test_05(self):
+ """
+ Test case:
+ Create a product level without computed nor manual level
+ Expected result:
+ Validation error (at least a value for one of these fields is
+ expected)
+ """
+ with self.assertRaises(ValidationError):
+ self.ProductLevel.create(
+ {
+ "product_id": self.product_1.id,
+ "profile_id": self.classification_profile.id,
+ }
+ )
+
+ def test_06_update_product_level_with_auto_compute(self):
+ self.classification_profile_bis.auto_apply_computed_value = True
+ self.product_level.write(
+ {
+ "computed_level_id": self.classification_level_bis_a.id,
+ "profile_id": self.classification_profile_bis.id,
+ }
+ )
+
+ self.assertEqual(
+ self.product_level.manual_level_id,
+ self.product_level.computed_level_id,
+ )
+ self.assertEqual(
+ self.product_level.computed_level_id, self.classification_level_bis_a
+ )
+ self.assertEqual(self.product_level.level_id, self.classification_level_bis_a)
+
+ self.product_level.write(
+ {
+ "computed_level_id": self.classification_level_bis_b.id,
+ }
+ )
+ self.assertEqual(
+ self.product_level.manual_level_id,
+ self.product_level.computed_level_id,
+ )
+ self.assertEqual(
+ self.product_level.computed_level_id, self.classification_level_bis_b
+ )
+ self.assertEqual(self.product_level.level_id, self.classification_level_bis_b)
+
+ def test_07_update_product_level_without_auto_compute(self):
+ self.classification_profile.auto_apply_computed_value = False
+ self.product_level.write(
+ {
+ "manual_level_id": self.classification_level_b.id,
+ "computed_level_id": self.classification_level_a.id,
+ "profile_id": self.classification_profile.id,
+ }
+ )
+
+ self.assertNotEqual(
+ self.product_level.manual_level_id,
+ self.product_level.computed_level_id,
+ )
+ self.assertEqual(
+ self.product_level.computed_level_id, self.classification_level_a
+ )
+ self.assertEqual(
+ self.product_level.manual_level_id, self.classification_level_b
+ )
+ self.assertEqual(self.product_level.level_id, self.classification_level_b)
+
+ self.product_level.write(
+ {
+ "manual_level_id": self.classification_level_a.id,
+ "computed_level_id": self.classification_level_b.id,
+ }
+ )
+
+ self.assertNotEqual(
+ self.product_level.manual_level_id,
+ self.product_level.computed_level_id,
+ )
+ self.assertEqual(
+ self.product_level.computed_level_id, self.classification_level_b
+ )
+ self.assertEqual(
+ self.product_level.manual_level_id, self.classification_level_a
+ )
+ self.assertEqual(self.product_level.level_id, self.classification_level_a)
+
+ def test_08_update_recordset_with__autocompute(self):
+ self._create_product_levels()
+ self.classification_profile.auto_apply_computed_value = True
+
+ levels = self.ProductLevel.search(
+ [("profile_id", "=", self.classification_profile.id)]
+ )
+ levels.write(
+ {
+ "manual_level_id": self.classification_level_a.id,
+ "computed_level_id": self.classification_level_b.id,
+ }
+ )
+
+ for level in levels:
+ self.assertEqual(level.manual_level_id, level.computed_level_id)
+ self.assertEqual(level.manual_level_id, self.classification_level_b)
+ self.assertEqual(level.computed_level_id, self.classification_level_b)
+ self.assertEqual(level.level_id, self.classification_level_b)
+
+ def test_09_update_recordset_and_change_profile(self):
+ self._create_product_levels()
+ self.classification_profile_bis.auto_apply_computed_value = True
+
+ levels = self.ProductLevel.search(
+ [("profile_id", "=", self.classification_profile.id)]
+ )
+ levels.write(
+ {
+ "computed_level_id": self.classification_level_bis_a.id,
+ "profile_id": self.classification_profile_bis.id,
+ }
+ )
+
+ for level in levels:
+ self.assertEqual(level.manual_level_id, level.computed_level_id)
+ self.assertEqual(level.manual_level_id, self.classification_level_bis_a)
+ self.assertEqual(level.computed_level_id, self.classification_level_bis_a)
+ self.assertEqual(level.level_id, self.classification_level_bis_a)
+
+ def test_10_create_product_level_for_profile_auto_assign(self):
+ self.classification_profile.auto_apply_computed_value = True
+ level = self.ProductLevel.create(
+ {
+ "product_id": self.product_1.id,
+ "manual_level_id": self.classification_level_b.id,
+ "computed_level_id": self.classification_level_a.id,
+ "profile_id": self.classification_profile.id,
+ }
+ )
+ self.assertEqual(level.manual_level_id, level.computed_level_id)
+ self.assertEqual(level.manual_level_id, self.classification_level_a)
+ self.assertEqual(level.computed_level_id, self.classification_level_a)
+ self.assertEqual(level.level_id, self.classification_level_a)
+
+ def test_11_auto_apply_computed_level(self):
+ self._create_product_levels()
+
+ levels = self.ProductLevel.search(
+ [("profile_id", "=", self.classification_profile.id)]
+ )
+ level0 = levels[0]
+ level1 = levels[1]
+ level2 = levels[2]
+ self.assertEqual(level0.manual_level_id, level0.computed_level_id)
+ self.assertEqual(level0.manual_level_id, self.classification_level_a)
+ self.assertEqual(level0.computed_level_id, self.classification_level_a)
+ self.assertEqual(level0.level_id, self.classification_level_a)
+
+ self.assertNotEqual(level1.manual_level_id, level1.computed_level_id)
+ self.assertEqual(level1.manual_level_id, self.classification_level_b)
+ self.assertEqual(level1.computed_level_id, self.classification_level_a)
+ self.assertEqual(level1.level_id, self.classification_level_b)
+
+ self.assertNotEqual(level2.manual_level_id, level2.computed_level_id)
+ self.assertEqual(level2.manual_level_id, self.classification_level_b)
+ self.assertEqual(level2.computed_level_id, self.classification_level_a)
+ self.assertEqual(level2.level_id, self.classification_level_b)
+
+ self.classification_profile.auto_apply_computed_value = True
+ for level in levels:
+ self.assertEqual(level.manual_level_id, self.classification_level_a)
+ self.assertEqual(level.computed_level_id, self.classification_level_a)
+ self.assertEqual(level.level_id, self.classification_level_a)
diff --git a/product_abc_classification/tests/test_abc_classification_profile.py b/product_abc_classification/tests/test_abc_classification_profile.py
new file mode 100644
index 00000000000..51691913cf2
--- /dev/null
+++ b/product_abc_classification/tests/test_abc_classification_profile.py
@@ -0,0 +1,300 @@
+# Copyright 2021 ACSONE SA/NV
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from psycopg2 import IntegrityError
+
+from odoo.exceptions import ValidationError
+
+from .common import ABCClassificationCase
+
+
+class TestABCClassificationProfile(ABCClassificationCase):
+ def test_00(self):
+ """
+ Data:
+ A test profile
+ Test case:
+ Assign levels for a total of 100%
+ Expected result:
+ OK
+ """
+ self.classification_profile.write(
+ {
+ "level_ids": [
+ (
+ 0,
+ 0,
+ {
+ "percentage": 60,
+ "percentage_products": 40,
+ "name": "A",
+ },
+ ),
+ (
+ 0,
+ 0,
+ {
+ "percentage": 40,
+ "percentage_products": 60,
+ "name": "B",
+ },
+ ),
+ ]
+ }
+ )
+ self.assertEqual(len(self.classification_profile.level_ids), 2)
+
+ def test_01(self):
+ """
+ Data:
+ A test profile
+ Test case:
+ Assign levels for a total < 100%
+ Expected result:
+ ValidationError
+ """
+ with self.assertRaises(ValidationError):
+ self.classification_profile.write(
+ {
+ "level_ids": [
+ (
+ 0,
+ 0,
+ {
+ "percentage": 60,
+ "percentage_products": 40,
+ "name": "A",
+ },
+ ),
+ (
+ 0,
+ 0,
+ {
+ "percentage": 30,
+ "percentage_products": 60,
+ "name": "B",
+ },
+ ),
+ ]
+ }
+ )
+
+ def test_02(self):
+ """
+ Data:
+ A test profile
+ Test case:
+ Assign levels for a total > 100%
+ Expected result:
+ ValidationError
+ """
+ with self.assertRaises(ValidationError):
+ self.classification_profile.write(
+ {
+ "level_ids": [
+ (
+ 0,
+ 0,
+ {
+ "percentage": 60,
+ "percentage_products": 40,
+ "name": "A",
+ },
+ ),
+ (
+ 0,
+ 0,
+ {
+ "percentage": 50,
+ "percentage_products": 60,
+ "name": "B",
+ },
+ ),
+ ]
+ }
+ )
+
+ def test_03(self):
+ """
+ Data:
+ A test profile
+ Test case:
+ Assign levels for a total = 100% but with same percentage
+ Expected result:
+ ValidationError
+ """
+ with self.assertRaises(ValidationError):
+ self.classification_profile.write(
+ {
+ "level_ids": [
+ (
+ 0,
+ 0,
+ {
+ "percentage": 50,
+ "percentage_products": 40,
+ "name": "A",
+ },
+ ),
+ (
+ 0,
+ 0,
+ {
+ "percentage": 50,
+ "percentage_products": 60,
+ "name": "B",
+ },
+ ),
+ ]
+ }
+ )
+
+ def test_04(self):
+ """
+ Data:
+ A test profile
+ Test case:
+ Assign levels for a total = 100% but with one level with negative
+ percentage and one level exceeding 100%
+ Expected result:
+ ValidationError
+ """
+ with self.assertRaises(ValidationError):
+ self.classification_profile.write(
+ {
+ "level_ids": [
+ (
+ 0,
+ 0,
+ {
+ "percentage": 150,
+ "percentage_products": 40,
+ "name": "A",
+ },
+ ),
+ (
+ 0,
+ 0,
+ {
+ "percentage": -50,
+ "percentage_products": 60,
+ "name": "B",
+ },
+ ),
+ ]
+ }
+ )
+
+ def test_05(self):
+ """
+ Data:
+ A test profile
+ Test case:
+ Assign levels for a total = 100% but with same name
+ Expected result:
+ IntegrityError (level name must be unique by profile)
+ """
+ with self.assertRaises(IntegrityError):
+ self.classification_profile.write(
+ {
+ "level_ids": [
+ (
+ 0,
+ 0,
+ {
+ "percentage": 60,
+ "percentage_products": 40,
+ "name": "A",
+ },
+ ),
+ (
+ 0,
+ 0,
+ {
+ "percentage": 40,
+ "percentage_products": 60,
+ "name": "A",
+ },
+ ),
+ ]
+ }
+ )
+
+ def test_06(self):
+ """
+ Data:
+ A test profile with 2 levels A and B
+ Test case:
+ Create a new profile with the same level name
+ Expected result:
+ Profile created without error since the level name is unique by
+ profile
+ """
+ self.classification_profile.write(
+ {
+ "level_ids": [
+ (
+ 0,
+ 0,
+ {
+ "percentage": 60,
+ "percentage_products": 40,
+ "name": "A",
+ },
+ ),
+ (
+ 0,
+ 0,
+ {
+ "percentage": 40,
+ "percentage_products": 60,
+ "name": "B",
+ },
+ ),
+ ]
+ }
+ )
+ new_profile = self.ABCClassificationProfile.create(
+ {
+ "name": "New Profile test",
+ "profile_type": "test_type",
+ "level_ids": [
+ (
+ 0,
+ 0,
+ {
+ "percentage": 60,
+ "percentage_products": 40,
+ "name": "A",
+ },
+ ),
+ (
+ 0,
+ 0,
+ {
+ "percentage": 40,
+ "percentage_products": 60,
+ "name": "B",
+ },
+ ),
+ ],
+ }
+ )
+ self.assertTrue(new_profile)
+
+ def test_07(self):
+ """
+ Data:
+ A test profile
+ Test case:
+ Create a new profile with the same name
+ Expected result:
+ IntegrityError (profile name must be unique by profile)
+ """
+ with self.assertRaises(IntegrityError):
+ self.ABCClassificationProfile.create(
+ {
+ "name": self.classification_profile.name,
+ "profile_type": "test_type",
+ }
+ )
diff --git a/product_abc_classification/tests/test_product.py b/product_abc_classification/tests/test_product.py
new file mode 100644
index 00000000000..923ae1eefe9
--- /dev/null
+++ b/product_abc_classification/tests/test_product.py
@@ -0,0 +1,109 @@
+# Copyright 2021 ACSONE SA/NV
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
+
+from .common import ABCClassificationLevelCase
+
+
+class TestProduct(ABCClassificationLevelCase):
+ @classmethod
+ def setUpClass(cls):
+ super().setUpClass()
+
+ def test_00(self):
+ """
+ Data:
+ A product template with one variant.
+ Test Case:
+ 1. Associate a classification profile to the template
+ 2. Unset the classifiation profile
+ Expected:
+ 1. The classification profile is also associated to the variant
+ 2. The classification profile no more associated to the variant
+ """
+ self.assertFalse(self.product_template.abc_classification_profile_ids)
+ self.assertFalse(self.product_product.abc_classification_profile_ids)
+ # 1
+ self.product_template.abc_classification_profile_ids = (
+ self.classification_profile
+ )
+ self.assertEqual(
+ self.product_product.abc_classification_profile_ids,
+ self.classification_profile,
+ )
+ # 2
+ self.product_template.abc_classification_profile_ids = False
+ self.assertFalse(self.product_product.abc_classification_profile_ids)
+
+ def test_01(self):
+ """
+ Data:
+ A product template with two variants (without profiles).
+ Test Case:
+ 1. Associate a classification profile to the template
+ Expected:
+ The classification profile is not associated to the variant
+ """
+ self._create_variant(self.size_attr_value_m)
+ variants = self.product_template.product_variant_ids
+ self.assertEqual(len(variants), 2)
+ self.assertFalse(variants.mapped("abc_classification_profile_ids"))
+ self.product_template.abc_classification_profile_ids = (
+ self.classification_profile
+ )
+ self.assertFalse(variants.mapped("abc_classification_profile_ids"))
+
+ def test_02(self):
+ """
+ Data:
+ A product template with one variant
+ Test Case:
+ 1 Associate a product level to the variant
+ 2 unlink the level
+ Expected result:
+ 1 The product level is also associated to the template
+ 2 No more level associated to the template
+ """
+ product_level = self.ProductLevel.create(
+ {
+ "product_id": self.product_product.id,
+ "computed_level_id": self.classification_level_a.id,
+ "profile_id": self.classification_profile.id,
+ }
+ )
+ self.assertEqual(
+ self.product_product.abc_classification_product_level_ids,
+ product_level,
+ )
+ self.assertEqual(
+ self.product_template.abc_classification_product_level_ids,
+ product_level,
+ )
+ product_level.unlink()
+
+ self.assertFalse(self.product_product.abc_classification_product_level_ids)
+ self.assertFalse(self.product_template.abc_classification_product_level_ids)
+
+ def test_03(self):
+ """
+ Data:
+ A product template with two variants
+ Test Case:
+ Associate a product level to one variant
+ Expected result:
+ The product level is not associated to the template
+ """
+ new_variant = self._create_variant(self.size_attr_value_m)
+ variants = self.product_template.product_variant_ids
+ self.assertEqual(len(variants), 2)
+ product_level = self.ProductLevel.create(
+ {
+ "product_id": new_variant.id,
+ "computed_level_id": self.classification_level_a.id,
+ "profile_id": self.classification_profile.id,
+ }
+ )
+ self.assertEqual(
+ new_variant.abc_classification_product_level_ids,
+ product_level,
+ )
+ self.assertFalse(self.product_template.abc_classification_product_level_ids)
diff --git a/product_abc_classification/views/abc_classification_product_level.xml b/product_abc_classification/views/abc_classification_product_level.xml
new file mode 100644
index 00000000000..71ac10979bd
--- /dev/null
+++ b/product_abc_classification/views/abc_classification_product_level.xml
@@ -0,0 +1,101 @@
+
+
+
+
+ abc.classification.product.level.form (in product_abc_classification)
+ abc.classification.product.level
+
+
+
+
+
+ abc.classification.product.level.tree (in product_abc_classification)
+ abc.classification.product.level
+
+
+
+
+
+
+
+
+
+
+
+ abc.classification.product.level.search (in product_abc_classification)
+ abc.classification.product.level
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Products ABC Classification
+ abc.classification.product.level
+ tree,form
+ {'search_default_group_by_level': 1}
+
+
+
diff --git a/product_abc_classification/views/abc_classification_profile.xml b/product_abc_classification/views/abc_classification_profile.xml
new file mode 100644
index 00000000000..f7eaf1f93da
--- /dev/null
+++ b/product_abc_classification/views/abc_classification_profile.xml
@@ -0,0 +1,54 @@
+
+
+
+
+ abc.classification.profile.form (in product_abc_classification)
+ abc.classification.profile
+
+
+
+
+
+ abc.classification.profile.tree (in product_abc_classification)
+ abc.classification.profile
+
+
+
+
+
+
+
+ ABC Classification profiles
+ abc.classification.profile
+ tree,form
+
+
+
diff --git a/product_abc_classification/views/product_product.xml b/product_abc_classification/views/product_product.xml
new file mode 100644
index 00000000000..ef810e32598
--- /dev/null
+++ b/product_abc_classification/views/product_product.xml
@@ -0,0 +1,19 @@
+
+
+
+
+ product.product.form (ABC Classification)
+ product.product
+
+
+
+ {'default_product_id': active_id, 'default_profile_id': abc_classification_profile_ids and abc_classification_profile_ids[0] and abc_classification_profile_ids[0][2] and abc_classification_profile_ids[0][2][0] or False}
+ {'read_only': False}
+ [('product_id', '=', active_id)]
+
+
+
+
diff --git a/product_abc_classification/views/product_template.xml b/product_abc_classification/views/product_template.xml
new file mode 100644
index 00000000000..1127c274e5f
--- /dev/null
+++ b/product_abc_classification/views/product_template.xml
@@ -0,0 +1,32 @@
+
+
+
+
+ product.template.form (ABC Classification)
+ product.template
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From 7242b45d49321c5cf298d8c9a7e2bb726b61dc76 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Miquel=20Ra=C3=AFch?=
Date: Wed, 2 Feb 2022 17:34:19 +0100
Subject: [PATCH 04/22] [IMP] product_abc_classification: add product smart
button in profile
---
.../models/abc_classification_profile.py | 37 +++++++++++++++++++
.../views/abc_classification_profile.xml | 10 +++++
2 files changed, 47 insertions(+)
diff --git a/product_abc_classification/models/abc_classification_profile.py b/product_abc_classification/models/abc_classification_profile.py
index b57b0a1e46d..6411834d0ee 100644
--- a/product_abc_classification/models/abc_classification_profile.py
+++ b/product_abc_classification/models/abc_classification_profile.py
@@ -30,6 +30,15 @@ class AbcClassificationProfile(models.Model):
required=True,
)
+ product_variant_ids = fields.Many2many(
+ comodel_name="product.product",
+ relation="abc_classification_profile_product_rel",
+ column1="profile_id",
+ column2="product_id",
+ index=True,
+ )
+ product_count = fields.Integer(compute="_compute_product_count", readonly=True)
+
auto_apply_computed_value = fields.Boolean(default=False)
_sql_constraints = [("name_uniq", "UNIQUE(name)", _("Profile name must be unique"))]
@@ -60,6 +69,34 @@ def _check_levels(self):
def _compute_abc_classification(self):
raise NotImplementedError()
+ @api.depends("product_variant_ids")
+ def _compute_product_count(self):
+ for profile in self:
+ profile.product_count = len(profile.product_variant_ids)
+
+ def action_view_products(self):
+ products = self.mapped("product_variant_ids")
+ action = self.env["ir.actions.act_window"].for_xml_id(
+ "product", "product_variant_action"
+ )
+ del action["context"]
+ if len(products) > 1:
+ action["domain"] = [("id", "in", products.ids)]
+ elif len(products) == 1:
+ form_view = [
+ (self.env.ref("product.product_variant_easy_edit_view").id, "form")
+ ]
+ if "views" in action:
+ action["views"] = form_view + [
+ (state, view) for state, view in action["views"] if view != "form"
+ ]
+ else:
+ action["views"] = form_view
+ action["res_id"] = products.id
+ else:
+ action = {"type": "ir.actions.act_window_close"}
+ return action
+
@api.model
def _cron_compute_abc_classification(self):
self.search([])._compute_abc_classification()
diff --git a/product_abc_classification/views/abc_classification_profile.xml b/product_abc_classification/views/abc_classification_profile.xml
index f7eaf1f93da..65830d78c4f 100644
--- a/product_abc_classification/views/abc_classification_profile.xml
+++ b/product_abc_classification/views/abc_classification_profile.xml
@@ -10,6 +10,16 @@
@@ -44,13 +41,13 @@
>abc.classification.product.level.tree (in product_abc_classification)
abc.classification.product.level
-
+
-
+
@@ -89,7 +86,7 @@
Products ABC Classification
abc.classification.product.level
- tree,form
+ list,form
{'search_default_group_by_level': 1}
ABC Classification profiles
abc.classification.profile
- tree,form
+ list,form
Do not contact contributors directly about support or help with technical issues.
@@ -453,7 +453,7 @@
OCA, or the Odoo Community Association, is a nonprofit organization whose
mission is to support the collaborative development of Odoo features and
promote its widespread use.
-
This module is part of the OCA/product-attribute project on GitHub.
+
This module is part of the OCA/product-attribute project on GitHub.
You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.
diff --git a/product_abc_classification/tests/common.py b/product_abc_classification/tests/common.py
index 1311f3f2c65..29ba1a8b461 100644
--- a/product_abc_classification/tests/common.py
+++ b/product_abc_classification/tests/common.py
@@ -102,11 +102,16 @@ def setUpClass(cls):
cls.size_attr_value_s = cls.size_attr.value_ids[0]
cls.size_attr_value_m = cls.size_attr.value_ids[1]
cls.uom_unit = cls.env.ref("uom.product_uom_unit")
+ cls.category_all = cls.env["product.category"].create(
+ {
+ "name": "All",
+ }
+ )
cls.product_template = cls.env["product.template"].create(
{
"name": "Test sized",
"uom_id": cls.uom_unit.id,
- "uom_po_id": cls.uom_unit.id,
+ "categ_id": cls.category_all.id,
"attribute_line_ids": [
(
0,
diff --git a/product_abc_classification/tests/test_abc_classification_product_level.py b/product_abc_classification/tests/test_abc_classification_product_level.py
index b995424feb0..18d7786cf71 100644
--- a/product_abc_classification/tests/test_abc_classification_product_level.py
+++ b/product_abc_classification/tests/test_abc_classification_product_level.py
@@ -17,7 +17,6 @@ def setUpClass(cls):
{
"name": "Test 1",
"uom_id": cls.uom_unit.id,
- "uom_po_id": cls.uom_unit.id,
}
)
cls.product_level = cls.ProductLevel.create(
@@ -34,7 +33,6 @@ def _create_product_levels(cls):
{
"name": "Test 2",
"uom_id": cls.uom_unit.id,
- "uom_po_id": cls.uom_unit.id,
}
)
@@ -42,7 +40,6 @@ def _create_product_levels(cls):
{
"name": "Test 3",
"uom_id": cls.uom_unit.id,
- "uom_po_id": cls.uom_unit.id,
}
)
cls.ProductLevel.create(
diff --git a/product_abc_classification/views/abc_classification_product_level.xml b/product_abc_classification/views/abc_classification_product_level.xml
index 9629c8efa8b..c131a985596 100644
--- a/product_abc_classification/views/abc_classification_product_level.xml
+++ b/product_abc_classification/views/abc_classification_product_level.xml
@@ -66,7 +66,7 @@
name="has_flag"
domain="[('flag','=',True)]"
/>
-
+