Skip to content

Commit

Permalink
Merge PR #623 into 13.0
Browse files Browse the repository at this point in the history
Signed-off-by dreispt
  • Loading branch information
OCA-git-bot committed Dec 2, 2021
2 parents b5ec077 + 7bf0c2e commit 6427513
Show file tree
Hide file tree
Showing 15 changed files with 517 additions and 0 deletions.
1 change: 1 addition & 0 deletions product_abc_classification/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
20 changes: 20 additions & 0 deletions product_abc_classification/__manifest__.py
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,
}
13 changes: 13 additions & 0 deletions product_abc_classification/data/ir_cron.xml
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>
4 changes: 4 additions & 0 deletions product_abc_classification/models/__init__.py
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 product_abc_classification/models/abc_classification_level.py
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 product_abc_classification/models/abc_classification_profile.py
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]
26 changes: 26 additions & 0 deletions product_abc_classification/models/product_category.py
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
)
74 changes: 74 additions & 0 deletions product_abc_classification/models/product_product.py
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
)
1 change: 1 addition & 0 deletions product_abc_classification/readme/CONTRIBUTORS.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* Miquel Raïch <miquel.raich@eficent.com>
8 changes: 8 additions & 0 deletions product_abc_classification/readme/DESCRIPTION.rst
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.
11 changes: 11 additions & 0 deletions product_abc_classification/readme/USAGE.rst
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).
5 changes: 5 additions & 0 deletions product_abc_classification/security/ir.model.access.csv
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.
Loading

0 comments on commit 6427513

Please sign in to comment.