-
-
Notifications
You must be signed in to change notification settings - Fork 673
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
72acbc6
commit 977a934
Showing
15 changed files
with
423 additions
and
0 deletions.
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/xyz_analysis_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_xyz_analysis_profile" /> | ||
<field name="code">model._compute_abc_analysis()</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 xyz_analysis_level | ||
from . import xyz_analysis_profile |
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). | ||
|
||
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 |
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 @@ | ||
# 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") |
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 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.")) |
154 changes: 154 additions & 0 deletions
154
product_abc_classification/models/xyz_analysis_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,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] |
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 analysis. |
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/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). |
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_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 |
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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,71 @@ | ||
<?xml version="1.0" encoding="UTF-8" ?> | ||
<!-- Copyright 2020 ForgeFlow | ||
License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). --> | ||
<odoo> | ||
<!-- product.product --> | ||
<record id="product_search_form_view" model="ir.ui.view"> | ||
<field name="name">product.product.search (ABC Classification)</field> | ||
<field name="model">product.product</field> | ||
<field name="inherit_id" ref="product.product_search_form_view" /> | ||
<field name="arch" type="xml"> | ||
<xpath expr="//group" position="inside"> | ||
<filter | ||
string="XYZ Profile" | ||
name="xyz_analysis_profile" | ||
context="{'group_by':'xyz_analysis_profile_id'}" | ||
/> | ||
<filter | ||
string="XYZ Level" | ||
name="xyz_analysis_level" | ||
context="{'group_by':'xyz_analysis_level_id'}" | ||
/> | ||
</xpath> | ||
</field> | ||
</record> | ||
<record id="product_product_tree_view" model="ir.ui.view"> | ||
<field name="name">product.product.tree</field> | ||
<field name="model">product.product</field> | ||
<field name="inherit_id" ref="product.product_product_tree_view" /> | ||
<field name="arch" type="xml"> | ||
<field name="categ_id" position="after"> | ||
<field name="xyz_analysis_profile_id" optional="hide" /> | ||
<field name="xyz_analysis_level_id" optional="hide" /> | ||
</field> | ||
</field> | ||
</record> | ||
<record id="product_normal_form_view" model="ir.ui.view"> | ||
<field name="name">product.product.form (ABC Classification)</field> | ||
<field name="model">product.product</field> | ||
<field name="inherit_id" ref="product.product_normal_form_view" /> | ||
<field name="arch" type="xml"> | ||
<notebook position="inside"> | ||
<page string="ABC Classification" name="abc_classification"> | ||
<group> | ||
<group> | ||
<field name="xyz_analysis_profile_id" /> | ||
<label for="xyz_analysis_level_id" /> | ||
<div> | ||
<field name="xyz_analysis_level_id" class="oe_inline" /> | ||
<span | ||
class="o_form_label oe_inline" | ||
attrs="{'invisible':[('xyz_analysis_level_id','=',False)]}" | ||
> %</span> | ||
</div> | ||
</group> | ||
</group> | ||
</page> | ||
</notebook> | ||
</field> | ||
</record> | ||
<!-- product.category --> | ||
<record id="product_category_form_view" model="ir.ui.view"> | ||
<field name="name">product.category.form (ABC Classification)</field> | ||
<field name="model">product.category</field> | ||
<field name="inherit_id" ref="product.product_category_form_view" /> | ||
<field name="arch" type="xml"> | ||
<group name="first" position="inside"> | ||
<field name="xyz_analysis_profile_id" /> | ||
</group> | ||
</field> | ||
</record> | ||
</odoo> |
Oops, something went wrong.