mirror of
https://github.com/OCA/manufacture.git
synced 2025-01-28 16:37:15 +02:00
381 lines
15 KiB
Python
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()
|