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..db92134c4da7 --- /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/xyz_analysis_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..caa8e4b1b276 --- /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_analysis() + code + + diff --git a/product_abc_classification/models/__init__.py b/product_abc_classification/models/__init__.py new file mode 100644 index 000000000000..8a86c7a39fe8 --- /dev/null +++ b/product_abc_classification/models/__init__.py @@ -0,0 +1,4 @@ +from . import product_category +from . import product_product +from . import xyz_analysis_level +from . import xyz_analysis_profile diff --git a/product_abc_classification/models/product_category.py b/product_abc_classification/models/product_category.py new file mode 100644 index 000000000000..4a1983788ade --- /dev/null +++ b/product_abc_classification/models/product_category.py @@ -0,0 +1,20 @@ +# 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" + + xyz_analysis_profile_id = fields.Many2one("xyz.analysis.profile") + product_variant_ids = fields.One2many("product.product", inverse_name="categ_id") + + @api.onchange("xyz_analysis_profile_id") + def _onchange_xyz_analysis_profile_id(self): + for categ in self: + for child in categ._origin.child_id: + child.xyz_analysis_profile_id = categ.xyz_analysis_profile_id + child._onchange_xyz_analysis_profile_id() + for variant in categ._origin.product_variant_ids: + variant.xyz_analysis_profile_id = categ.xyz_analysis_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..75fd6f3b781a --- /dev/null +++ b/product_abc_classification/models/product_product.py @@ -0,0 +1,11 @@ +# Copyright 2020 ForgeFlow +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo import fields, models + + +class ProductProduct(models.Model): + _inherit = "product.product" + + xyz_analysis_profile_id = fields.Many2one("xyz.analysis.profile") + xyz_analysis_level_id = fields.Many2one("xyz.analysis.profile.level") diff --git a/product_abc_classification/models/xyz_analysis_level.py b/product_abc_classification/models/xyz_analysis_level.py new file mode 100644 index 000000000000..9a58a9243257 --- /dev/null +++ b/product_abc_classification/models/xyz_analysis_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 XYZAnalysisProfileLevel(models.Model): + _name = "xyz.analysis.profile.level" + _description = "XYZ Analysis Profile Level" + _order = "percentage desc, id desc" + + percentage = fields.Float(default=0.0, required=True, string="%") + profile_id = fields.Many2one("xyz.analysis.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/xyz_analysis_profile.py b/product_abc_classification/models/xyz_analysis_profile.py new file mode 100644 index 000000000000..bc04b340fe27 --- /dev/null +++ b/product_abc_classification/models/xyz_analysis_profile.py @@ -0,0 +1,154 @@ +# 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 XYZAnalysisProfile(models.Model): + _name = "xyz.analysis.profile" + _description = "XYZ Analysis Profile" + + name = fields.Char() + level_ids = fields.One2many( + comodel_name="xyz.analysis.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="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() + .search( + [ + ("sale_line_id.state", "in", ["sale", "done"]), + ("sale_line_id.order_id.date_order", ">", date), + ("location_dest_id.usage", "=", "customer"), + ("location_id.usage", "!=", "customer"), + "|", + ("product_id.xyz_analysis_profile_id", "=", self.id), + "|", + ("product_id.categ_id.xyz_analysis_profile_id", "=", self.id), + ( + "product_id.categ_id.parent_id.xyz_analysis_profile_id", + "=", + self.id, + ), + ] + ) + ) + uom_id = self.env.ref("uom.product_uom_unit") + for product in moves.mapped("product_id"): + product_data = { + "product": product, + "units_sold": 0, + } + for move in moves.filtered(lambda m: m.product_id == product): + product_data["units_sold"] += move.product_uom._compute_quantity( + move.product_uom_qty, uom_id, "HALF-UP" + ) + 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_analysis(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"].xyz_analysis_level_id = level_percentage[0][0] 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..869820d1abeb --- /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 analysis. diff --git a/product_abc_classification/readme/USAGE.rst b/product_abc_classification/readme/USAGE.rst new file mode 100644 index 000000000000..aee82cd63784 --- /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/XYZ Analysis 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 analysis 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..a1a33fab1479 --- /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_xyz_analysis_profile_user,xyz.analysis.profile.user,model_xyz_analysis_profile,base.group_user,1,0,0,0 +access_xyz_analysis_profile_manager,xyz.analysis.profile.manager,model_xyz_analysis_profile,base.group_system,1,1,1,1 +access_xyz_analysis_profile_level_user,xyz.analysis.profile.level.user,model_xyz_analysis_profile_level,base.group_user,1,0,0,0 +access_xyz_analysis_profile_level_manager,xyz.analysis.profile.level.manager,model_xyz_analysis_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/product_view.xml b/product_abc_classification/views/product_view.xml new file mode 100644 index 000000000000..2c68b8ba0f22 --- /dev/null +++ b/product_abc_classification/views/product_view.xml @@ -0,0 +1,71 @@ + + + + + + product.product.search (ABC Classification) + product.product + + + + + + + + + + product.product.tree + product.product + + + + + + + + + + product.product.form (ABC Classification) + product.product + + + + + + + + + + + + + + + + product.category.form (ABC Classification) + product.category + + + + + + + + diff --git a/product_abc_classification/views/xyz_analysis_view.xml b/product_abc_classification/views/xyz_analysis_view.xml new file mode 100644 index 000000000000..dd7273f24182 --- /dev/null +++ b/product_abc_classification/views/xyz_analysis_view.xml @@ -0,0 +1,69 @@ + + + + + xyz.analysis.profile.form + xyz.analysis.profile + +
+ + + + + + + + + + + + + + + + + +
+
+
+ + xyz.analysis.profile.tree + xyz.analysis.profile + + + + + + + + + XYZ Analysis Profile + xyz.analysis.profile + tree,form + +

+ Click to add a new profile. +

+

+ This allows to create an ABC classification. +

+
+
+ + +