diff --git a/product_abc_classification/__init__.py b/product_abc_classification/__init__.py new file mode 100644 index 000000000000..0650744f6bc6 --- /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 000000000000..d67fd6bc574d --- /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 000000000000..36faa7971a5e --- /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 000000000000..39394627c878 --- /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 000000000000..2b6790c6b5be --- /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 000000000000..f75be6575589 --- /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 000000000000..df7348e554cb --- /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 000000000000..99ce94704818 --- /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 000000000000..2e34e218a547 --- /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 000000000000..b55e108765fd --- /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 000000000000..a66526e486c7 --- /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 000000000000..421beb328a90 --- /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 000000000000..3a0328b516c4 Binary files /dev/null and b/product_abc_classification/static/description/icon.png differ 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 000000000000..001fd3b21e63 --- /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 000000000000..5caf6a28283a --- /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 + + + + + + + +