diff --git a/product_abc_classification/README.rst b/product_abc_classification/README.rst new file mode 100644 index 000000000000..bbed589ebf53 --- /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 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..310fe0ad7343 --- /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 000000000000..04303328c198 --- /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 000000000000..82b0a6c960a3 --- /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_terms:ir.ui.view,arch_db:product_abc_classification.product_template_form_view +msgid "ABC Classification" +msgstr "Classification ABC" + +#. module: product_abc_classification +#: model_terms: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_terms:ir.ui.view,arch_db:product_abc_classification.abc_classification_profile_form_view +msgid "ABC Profile" +msgstr "Profil ABC" + +#. module: product_abc_classification +#: model_terms: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_terms: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_terms: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_terms: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_terms: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_terms: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_terms: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 000000000000..b98adc64bd49 --- /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 000000000000..01b045eabc72 --- /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 000000000000..7def032e335b --- /dev/null +++ b/product_abc_classification/models/abc_classification_product_level.py @@ -0,0 +1,181 @@ +# 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): + """ + We apply the manual level to the product level if + computed level is modified and only for profiles with + auto_apply_computed_value = =True + """ + values = vals.copy() + new_self = self + if "computed_level_id" in values: + profile_obj = self.env["abc.classification.profile"] + target_profile_id = ( + profile_obj.browse(values["profile_id"]).filtered( + "auto_apply_computed_value" + ) + if "profile_id" in values + else profile_obj.browse() + ) + if target_profile_id: + # If the profile of levels should be changed at the same time + # and has auto_apply_computed_value True + # So, we can apply change to the whole recordset + values["manual_level_id"] = values["computed_level_id"] + else: + # If profile is not modified, filter levels per profile + # if it has auto_apply_computed_value True and modify only + # those ones + auto_applied_profiles_levels = self.filtered( + lambda l: l.profile_id.auto_apply_computed_value + ) + new_self = self - auto_applied_profiles_levels + super( + AbcClassificationProductLevel, auto_applied_profiles_levels + ).write(dict(values, manual_level_id=values["computed_level_id"])) + return super(AbcClassificationProductLevel, new_self).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 000000000000..dc7fd29f3ad8 --- /dev/null +++ b/product_abc_classification/models/abc_classification_profile.py @@ -0,0 +1,137 @@ +# 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, + ) + + 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, + help="Check this if you want to apply the computed level on each product that has this " + "profile.", + ) + + _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.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() + + 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 000000000000..80f1fc1513f3 --- /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 000000000000..48c01bbc47d0 --- /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 000000000000..fe41e2ce43dd --- /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 000000000000..e8bc6d5b704d --- /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 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..9283b3539606 --- /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 000000000000..3a0328b516c4 Binary files /dev/null and b/product_abc_classification/static/description/icon.png differ diff --git a/product_abc_classification/static/description/index.html b/product_abc_classification/static/description/index.html new file mode 100644 index 000000000000..b309d87953d4 --- /dev/null +++ b/product_abc_classification/static/description/index.html @@ -0,0 +1,443 @@ + + + + + + +Product Abc Classification + + + +
+

Product Abc Classification

+ + +

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

+

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

+ +
+

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

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

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 000000000000..8292c06ca325 --- /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 000000000000..d4c24f0cdf40 --- /dev/null +++ b/product_abc_classification/tests/common.py @@ -0,0 +1,128 @@ +# Copyright 2021 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.fields import Command +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": [ + Command.set( + size_value.pav_attribute_line_ids.product_template_value_ids.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 000000000000..9b2db6df8722 --- /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 000000000000..68044ba97bfe --- /dev/null +++ b/product_abc_classification/tests/test_abc_classification_profile.py @@ -0,0 +1,303 @@ +# 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 odoo.tools.misc import mute_logger + +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", + }, + ), + ] + } + ) + + @mute_logger("odoo.sql_db") + 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) + + @mute_logger("odoo.sql_db") + 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 000000000000..add08143a4a3 --- /dev/null +++ b/product_abc_classification/tests/test_product.py @@ -0,0 +1,139 @@ +# 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) + + def test_04(self): + """ + Data: + A product template + Test case: + Check if resource id in action is the product variant one + """ + self.product_template.abc_classification_profile_ids = ( + self.classification_profile + ) + action = self.classification_profile.action_view_products() + self.assertEqual(action["res_id"], self.product_template.product_variant_ids.id) + + def test_05(self): + """ + Data: + A product template with two variants + Test case: + Check if doamin in action is the product variants ids + """ + self._create_variant(self.size_attr_value_m) + self.product_template.product_variant_ids.abc_classification_profile_ids = ( + self.classification_profile + ) + action = self.classification_profile.action_view_products() + self.assertEqual( + action["domain"], + [("id", "in", self.product_template.product_variant_ids.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 000000000000..71ac10979bd2 --- /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 000000000000..89b078b40dac --- /dev/null +++ b/product_abc_classification/views/abc_classification_profile.xml @@ -0,0 +1,85 @@ + + + + + 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 000000000000..b6b19dc26ca6 --- /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] 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 000000000000..2f2873568fc7 --- /dev/null +++ b/product_abc_classification/views/product_template.xml @@ -0,0 +1,32 @@ + + + + + product.template.form (ABC Classification) + product.template + + + + + + + + + + + + + + + diff --git a/setup/product_abc_classification/odoo/addons/product_abc_classification b/setup/product_abc_classification/odoo/addons/product_abc_classification new file mode 120000 index 000000000000..8571d22e4ad8 --- /dev/null +++ b/setup/product_abc_classification/odoo/addons/product_abc_classification @@ -0,0 +1 @@ +../../../../product_abc_classification \ No newline at end of file diff --git a/setup/product_abc_classification/setup.py b/setup/product_abc_classification/setup.py new file mode 100644 index 000000000000..28c57bb64031 --- /dev/null +++ b/setup/product_abc_classification/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)