-
-
Notifications
You must be signed in to change notification settings - Fork 673
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[13.0][ADD] product_abc_classification #623
Merged
OCA-git-bot
merged 1 commit into
OCA:13.0
from
ForgeFlow:13.0-add-product_ABC_classification
Dec 2, 2021
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
from . import models |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
<?xml version="1.0" encoding="utf-8" ?> | ||
<odoo> | ||
<record id="ir_cron_product_abc_classification" model="ir.cron"> | ||
<field name="name">Perform the product ABC Classification</field> | ||
<field name="interval_number">1</field> | ||
<field name="interval_type">days</field> | ||
<field name="numbercall">-1</field> | ||
<field name="doall" eval="False" /> | ||
<field name="model_id" ref="model_abc_classification_profile" /> | ||
<field name="code">model._compute_abc_classification()</field> | ||
<field name="state">code</field> | ||
</record> | ||
</odoo> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
from . import product_category | ||
from . import product_product | ||
from . import abc_classification_level | ||
from . import abc_classification_profile |
35 changes: 35 additions & 0 deletions
35
product_abc_classification/models/abc_classification_level.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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.")) |
156 changes: 156 additions & 0 deletions
156
product_abc_classification/models/abc_classification_profile.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
* Miquel Raïch <miquel.raich@eficent.com> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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). |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Hi.
I don't understand why the abc_classification_profile_id is in the variant level, and not in the template level. Does it make sense to set a profile 1 for a variant, and a profile 2 to another variant of the same template ?
Propose to move the profile in the template level. (and just set a related in the variant level).
What do you think ?
Also, it could be great to have an api@depends("categ_id") to set the correct profile automatically when creating a new product, linked to a product_category with a profile defined.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@legalsylvain Sorry, I didn't have time to check this comment. Well, now if you think the code can be improved, then you can propose a PR for this version or do the refactor in the next one.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Arf... this require to write migration script to change the model.
I'll see.
But well, it doesn't answer to my question : Does it make sense to set a profile 1 for a variant, and a profile 2 to another variant of the same template ?
Thanks.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@legalsylvain From my POV it makes more sense in the variant. You can have a variant that is more successful in the market than other, and therefore you classify one as A and other as B.
My 2 cents.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not sure you get it.
Of course the level a/b/c should be in the variant.
But the perfil is common for all the variant of a product template, by design.
Concrete counter exemple welcome.