Files
Akim Juillerat ad3902f1f1 [FIX] mrp_bom_attribute_match: Do not empty bom line uom when creating
When creating a new line, an onchange is played before the user selects
the product. With the previous code, since no product was selected,
this was emptying the default value. This was in turn breaking
any other function that relied on this value being set.
2024-11-11 16:59:31 +01:00

385 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_id.uom_id
and 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.sudo().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()