Files
manufacture/mrp_bom_attribute_match/models/mrp_bom.py

381 lines
15 KiB
Python

import logging
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
from odoo.tools import float_round
_log = logging.getLogger(__name__)
class MrpBomLine(models.Model):
_inherit = "mrp.bom.line"
product_id = fields.Many2one("product.product", "Component", required=False)
product_backup_id = fields.Many2one(
"product.product", help="Technical field to store previous value of product_id"
)
component_template_id = fields.Many2one(
"product.template",
"Component (product template)",
domain="[('id', '!=', parent_product_tmpl_id)]",
)
match_on_attribute_ids = fields.Many2many(
"product.attribute",
string="Match on Attributes",
compute="_compute_match_on_attribute_ids",
store=True,
)
product_uom_category_id = fields.Many2one(
"uom.category",
related=None,
compute="_compute_product_uom_category_id",
)
@api.constrains("component_template_id")
def _check_component_template_recursion(self):
for line in self:
if line.component_template_id == line.parent_product_tmpl_id:
raise ValidationError(
_(
"Component template must be different from BOM "
"product template. Please check BOM: %s BOM Line: %s"
)
% (line, line.bom_id)
)
@api.depends("product_id", "component_template_id")
def _compute_product_uom_category_id(self):
"""Compute the product_uom_category_id field.
This is the product category that will be allowed to use on the product_uom_id
field, already covered by core module:
https://github.com/odoo/odoo/blob/331b9435c/addons/mrp/models/mrp_bom.py#L372
In core, though, this field is related to "product_id.uom_id.category_id".
Here we make it computed to choose between component_template_id and
product_id, depending on which one is set
"""
# pylint: disable=missing-return
# NOTE: To play nice with other modules trying to do the same:
# 1) Set the field value as if it were a related field (core behaviour)
# 2) Call super (if it's there)
# 3) Update only the records we want
for rec in self:
rec.product_uom_category_id = rec.product_id.uom_id.category_id
if hasattr(super(), "_compute_product_uom_category_id"):
super()._compute_product_uom_category_id()
for rec in self:
if rec.component_template_id:
rec.product_uom_category_id = (
rec.component_template_id.uom_id.category_id
)
@api.onchange("component_template_id")
def _onchange_component_template_id(self):
if self.component_template_id:
if self.product_id:
self.product_backup_id = self.product_id
self.product_id = False
if (
self.product_uom_id.category_id
!= self.component_template_id.uom_id.category_id
):
self.product_uom_id = self.component_template_id.uom_id
else:
if self.product_backup_id:
self.product_id = self.product_backup_id
self.product_backup_id = False
if self.product_uom_id.category_id != self.product_id.uom_id.category_id:
self.product_uom_id = self.product_id.uom_id
@api.depends("component_template_id")
def _compute_match_on_attribute_ids(self):
for rec in self:
if rec.component_template_id:
rec.match_on_attribute_ids = (
rec.component_template_id.attribute_line_ids.attribute_id.filtered(
lambda x: x.create_variant != "no_variant"
)
)
else:
rec.match_on_attribute_ids = False
@api.constrains("component_template_id")
def _check_component_attributes(self):
for rec in self:
if not rec.component_template_id:
continue
comp_attrs = (
rec.component_template_id.valid_product_template_attribute_line_ids.attribute_id
)
prod_attrs = (
rec.bom_id.product_tmpl_id.valid_product_template_attribute_line_ids.attribute_id
)
if not comp_attrs:
raise ValidationError(
_(
"No match on attribute has been detected for Component "
"(Product Template) %s",
rec.component_template_id.display_name,
)
)
if not all(attr in prod_attrs for attr in comp_attrs):
raise ValidationError(
_(
"Some attributes of the dynamic component are not included into "
"production product attributes."
)
)
@api.constrains("component_template_id", "bom_product_template_attribute_value_ids")
def _check_variants_validity(self):
for rec in self:
if (
not rec.bom_product_template_attribute_value_ids
or not rec.component_template_id
):
continue
variant_attrs = rec.bom_product_template_attribute_value_ids.attribute_id
same_attr_ids = set(rec.match_on_attribute_ids.ids) & set(variant_attrs.ids)
same_attrs = self.env["product.attribute"].browse(same_attr_ids)
if same_attrs:
raise ValidationError(
_(
"You cannot use an attribute value for attribute(s) %(attributes)s "
"in the field “Apply on Variants” as it's the same attribute used "
"in the field “Match on Attribute” related to the component "
"%(component)s.",
attributes=", ".join(same_attrs.mapped("name")),
component=rec.component_template_id.name,
)
)
@api.onchange("match_on_attribute_ids")
def _onchange_match_on_attribute_ids_check_component_attributes(self):
if self.match_on_attribute_ids:
self._check_component_attributes()
@api.onchange("bom_product_template_attribute_value_ids")
def _onchange_bom_product_template_attribute_value_ids_check_variants(self):
if self.bom_product_template_attribute_value_ids:
self._check_variants_validity()
def _skip_bom_line(self, product):
# Make this method compatible to work with NewIds
res = super()._skip_bom_line(product)
return res and (
len(
set(product.product_template_attribute_value_ids.ids)
& set(self.bom_product_template_attribute_value_ids.ids)
)
!= len(self.bom_product_template_attribute_value_ids.attribute_id)
)
class MrpBom(models.Model):
_inherit = "mrp.bom"
# flake8: noqa: C901
def explode(self, product, quantity, picking_type=False):
# Had to replace this method
"""
Explodes the BoM and creates two lists with all the information you need:
bom_done and line_done
Quantity describes the number of times you need the BoM: so the quantity
divided by the number created by the BoM
and converted into its UoM
"""
from collections import defaultdict
graph = defaultdict(list)
V = set()
def check_cycle(v, visited, recStack, graph):
visited[v] = True
recStack[v] = True
for neighbour in graph[v]:
if visited[neighbour] is False:
if check_cycle(neighbour, visited, recStack, graph) is True:
return True
elif recStack[neighbour] is True:
return True
recStack[v] = False
return False
product_ids = set()
product_boms = {}
def update_product_boms():
products = self.env["product.product"].browse(product_ids)
product_boms.update(
self._get_product2bom(
products,
bom_type="phantom",
picking_type=picking_type or self.picking_type_id,
company_id=self.company_id.id,
)
)
# Set missing keys to default value
for product in products:
product_boms.setdefault(product, self.env["mrp.bom"])
boms_done = [
(
self,
{
"qty": quantity,
"product": product,
"original_qty": quantity,
"parent_line": False,
},
)
]
lines_done = []
V |= {product.product_tmpl_id.id}
bom_lines = []
for bom_line in self.bom_line_ids:
product_id = bom_line.product_id
V |= {product_id.product_tmpl_id.id}
graph[product.product_tmpl_id.id].append(product_id.product_tmpl_id.id)
bom_lines.append((bom_line, product, quantity, False))
product_ids.add(product_id.id)
update_product_boms()
product_ids.clear()
while bom_lines:
current_line, current_product, current_qty, parent_line = bom_lines[0]
bom_lines = bom_lines[1:]
if current_line._skip_bom_line(current_product):
continue
line_quantity = current_qty * current_line.product_qty
if current_line.product_id not in product_boms:
update_product_boms()
product_ids.clear()
# upd start
component_template_product = self._get_component_template_product(
current_line, product, current_line.product_id
)
if component_template_product:
# need to set product_id temporary
current_line.product_id = component_template_product
else:
# component_template_id is set, but no attribute value match.
continue
# upd end
bom = product_boms.get(current_line.product_id)
if bom:
converted_line_quantity = current_line.product_uom_id._compute_quantity(
line_quantity / bom.product_qty, bom.product_uom_id
)
bom_lines += [
(
line,
current_line.product_id,
converted_line_quantity,
current_line,
)
for line in bom.bom_line_ids
]
for bom_line in bom.bom_line_ids:
graph[current_line.product_id.product_tmpl_id.id].append(
bom_line.product_id.product_tmpl_id.id
)
if bom_line.product_id.product_tmpl_id.id in V and check_cycle(
bom_line.product_id.product_tmpl_id.id,
{key: False for key in V},
{key: False for key in V},
graph,
):
raise UserError(
_(
"Recursion error! A product with a Bill of Material "
"should not have itself in its BoM or child BoMs!"
)
)
V |= {bom_line.product_id.product_tmpl_id.id}
if bom_line.product_id not in product_boms:
product_ids.add(bom_line.product_id.id)
boms_done.append(
(
bom,
{
"qty": converted_line_quantity,
"product": current_product,
"original_qty": quantity,
"parent_line": current_line,
},
)
)
else:
# We round up here because the user expects
# that if he has to consume a little more, the whole UOM unit
# should be consumed.
rounding = current_line.product_uom_id.rounding
line_quantity = float_round(
line_quantity, precision_rounding=rounding, rounding_method="UP"
)
lines_done.append(
(
current_line,
{
"qty": line_quantity,
"product": current_product,
"original_qty": quantity,
"parent_line": parent_line,
},
)
)
return boms_done, lines_done
def _get_component_template_product(
self, bom_line, bom_product_id, line_product_id
):
if bom_line.component_template_id:
comp = bom_line.component_template_id
comp_attr_ids = (
comp.valid_product_template_attribute_line_ids.attribute_id.ids
)
prod_attr_ids = (
bom_product_id.valid_product_template_attribute_line_ids.attribute_id.ids
)
# check attributes
if not all(item in prod_attr_ids for item in comp_attr_ids):
_log.info(
"Component skipped. Component attributes must be included into "
"product attributes to use component_template_id."
)
return False
# find matching combination
combination = self.env["product.template.attribute.value"]
for ptav in bom_product_id.product_template_attribute_value_ids:
combination |= self.env["product.template.attribute.value"].search(
[
("product_tmpl_id", "=", comp.id),
("attribute_id", "=", ptav.attribute_id.id),
(
"product_attribute_value_id",
"=",
ptav.product_attribute_value_id.id,
),
]
)
if len(combination) == 0:
return False
product_id = comp._get_variant_for_combination(combination)
if product_id and product_id.active:
return product_id
return False
else:
return line_product_id
@api.constrains("product_tmpl_id", "product_id")
def _check_component_attributes(self):
return self.bom_line_ids._check_component_attributes()
@api.constrains("product_tmpl_id", "product_id")
def _check_variants_validity(self):
return self.bom_line_ids._check_variants_validity()