Files
odoo_source/addons/mrp/models/mrp_bom.py
T
Nicolas Pierre d9f73a9ce6 [FIX] mrp: archive QC linked to archived operation
Instead of the delete icon next to the operations in a BoM, create a
button to archive the operation. Archiving an operation will also
archive the corresponding Quality Check.

closes odoo/odoo#77456

Task-id: 2619450
Community-pr: https://github.com/odoo/odoo/pull/76376
Enterprise-pr: https://github.com/odoo/enterprise/pull/20785
X-original-commit: 9761ef89ca0f12f2907d844451e56c4a833ee8ef
Related: odoo/enterprise#21291
Signed-off-by: William Henrotin <Whenrow@users.noreply.github.com>
2021-09-30 13:32:01 +00:00

513 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
from odoo.osv.expression import AND, NEGATIVE_TERM_OPERATORS
from odoo.tools import float_round
from collections import defaultdict
class MrpBom(models.Model):
""" Defines bills of material for a product or a product template """
_name = 'mrp.bom'
_description = 'Bill of Material'
_inherit = ['mail.thread']
_rec_name = 'product_tmpl_id'
_order = "sequence, id"
_check_company_auto = True
def _get_default_product_uom_id(self):
return self.env['uom.uom'].search([], limit=1, order='id').id
code = fields.Char('Reference')
active = fields.Boolean(
'Active', default=True,
help="If the active field is set to False, it will allow you to hide the bills of material without removing it.")
type = fields.Selection([
('normal', 'Manufacture this product'),
('phantom', 'Kit')], 'BoM Type',
default='normal', required=True)
product_tmpl_id = fields.Many2one(
'product.template', 'Product',
check_company=True, index=True,
domain="[('type', 'in', ['product', 'consu']), '|', ('company_id', '=', False), ('company_id', '=', company_id)]", required=True)
product_id = fields.Many2one(
'product.product', 'Product Variant',
check_company=True, index=True,
domain="['&', ('product_tmpl_id', '=', product_tmpl_id), ('type', 'in', ['product', 'consu']), '|', ('company_id', '=', False), ('company_id', '=', company_id)]",
help="If a product variant is defined the BOM is available only for this product.")
bom_line_ids = fields.One2many('mrp.bom.line', 'bom_id', 'BoM Lines', copy=True)
byproduct_ids = fields.One2many('mrp.bom.byproduct', 'bom_id', 'By-products', copy=True)
product_qty = fields.Float(
'Quantity', default=1.0,
digits='Unit of Measure', required=True)
product_uom_id = fields.Many2one(
'uom.uom', 'Unit of Measure',
default=_get_default_product_uom_id, required=True,
help="Unit of Measure (Unit of Measure) is the unit of measurement for the inventory control", domain="[('category_id', '=', product_uom_category_id)]")
product_uom_category_id = fields.Many2one(related='product_tmpl_id.uom_id.category_id')
sequence = fields.Integer('Sequence', help="Gives the sequence order when displaying a list of bills of material.")
operation_ids = fields.One2many('mrp.routing.workcenter', 'bom_id', 'Operations', copy=True)
ready_to_produce = fields.Selection([
('all_available', ' When all components are available'),
('asap', 'When components for 1st operation are available')], string='Manufacturing Readiness',
default='all_available', help="Defines when a Manufacturing Order is considered as ready to be started", required=True)
picking_type_id = fields.Many2one(
'stock.picking.type', 'Operation Type', domain="[('code', '=', 'mrp_operation'), ('company_id', '=', company_id)]",
check_company=True,
help=u"When a procurement has a ‘produce’ route with a operation type set, it will try to create "
"a Manufacturing Order for that product using a BoM of the same operation type. That allows "
"to define stock rules which trigger different manufacturing orders with different BoMs.")
company_id = fields.Many2one(
'res.company', 'Company', index=True,
default=lambda self: self.env.company)
consumption = fields.Selection([
('flexible', 'Allowed'),
('warning', 'Allowed with warning'),
('strict', 'Blocked')],
help="Defines if you can consume more or less components than the quantity defined on the BoM:\n"
" * Allowed: allowed for all manufacturing users.\n"
" * Allowed with warning: allowed for all manufacturing users with summary of consumption differences when closing the manufacturing order.\n"
" * Blocked: only a manager can close a manufacturing order when the BoM consumption is not respected.",
default='warning',
string='Flexible Consumption',
required=True
)
possible_product_template_attribute_value_ids = fields.Many2many(
'product.template.attribute.value',
compute='_compute_possible_product_template_attribute_value_ids')
_sql_constraints = [
('qty_positive', 'check (product_qty > 0)', 'The quantity to produce must be positive!'),
]
@api.depends(
'product_tmpl_id.attribute_line_ids.value_ids',
'product_tmpl_id.attribute_line_ids.attribute_id.create_variant',
'product_tmpl_id.attribute_line_ids.product_template_value_ids.ptav_active',
)
def _compute_possible_product_template_attribute_value_ids(self):
for bom in self:
bom.possible_product_template_attribute_value_ids = bom.product_tmpl_id.valid_product_template_attribute_line_ids._without_no_variant_attributes().product_template_value_ids._only_active()
@api.onchange('product_id')
def _onchange_product_id(self):
if self.product_id:
self.bom_line_ids.bom_product_template_attribute_value_ids = False
self.operation_ids.bom_product_template_attribute_value_ids = False
self.byproduct_ids.bom_product_template_attribute_value_ids = False
@api.constrains('product_id', 'product_tmpl_id', 'bom_line_ids', 'byproduct_ids', 'operation_ids')
def _check_bom_lines(self):
for bom in self:
for bom_line in bom.bom_line_ids:
if bom.product_id:
same_product = bom.product_id == bom_line.product_id
else:
same_product = bom.product_tmpl_id == bom_line.product_id.product_tmpl_id
if same_product:
raise ValidationError(_("BoM line product %s should not be the same as BoM product.") % bom.display_name)
apply_variants = bom.bom_line_ids.bom_product_template_attribute_value_ids | bom.operation_ids.bom_product_template_attribute_value_ids | bom.byproduct_ids.bom_product_template_attribute_value_ids
if bom.product_id and apply_variants:
raise ValidationError(_("You cannot use the 'Apply on Variant' functionality and simultaneously create a BoM for a specific variant."))
for ptav in apply_variants:
if ptav.product_tmpl_id != bom.product_tmpl_id:
raise ValidationError(_(
"The attribute value %(attribute)s set on product %(product)s does not match the BoM product %(bom_product)s.",
attribute=ptav.display_name,
product=ptav.product_tmpl_id.display_name,
bom_product=bom_line.parent_product_tmpl_id.display_name
))
for byproduct in bom.byproduct_ids:
if bom.product_id:
same_product = bom.product_id == byproduct.product_id
else:
same_product = bom.product_tmpl_id == byproduct.product_id.product_tmpl_id
if same_product:
raise ValidationError(_("By-product %s should not be the same as BoM product.") % bom.display_name)
if byproduct.cost_share < 0:
raise ValidationError(_("By-products cost shares must be positive."))
if sum(bom.byproduct_ids.mapped('cost_share')) > 100:
raise ValidationError(_("The total cost share for a BoM's by-products cannot exceed 100."))
@api.onchange('product_uom_id')
def onchange_product_uom_id(self):
res = {}
if not self.product_uom_id or not self.product_tmpl_id:
return
if self.product_uom_id.category_id.id != self.product_tmpl_id.uom_id.category_id.id:
self.product_uom_id = self.product_tmpl_id.uom_id.id
res['warning'] = {'title': _('Warning'), 'message': _('The Product Unit of Measure you chose has a different category than in the product form.')}
return res
@api.onchange('product_tmpl_id')
def onchange_product_tmpl_id(self):
if self.product_tmpl_id:
self.product_uom_id = self.product_tmpl_id.uom_id.id
if self.product_id.product_tmpl_id != self.product_tmpl_id:
self.product_id = False
self.bom_line_ids.bom_product_template_attribute_value_ids = False
self.operation_ids.bom_product_template_attribute_value_ids = False
self.byproduct_ids.bom_product_template_attribute_value_ids = False
domain = [('product_tmpl_id', '=', self.product_tmpl_id.id)]
if self.id.origin:
domain.append(('id', '!=', self.id.origin))
number_of_bom_of_this_product = self.env['mrp.bom'].search_count(domain)
if number_of_bom_of_this_product: # add a reference to the bom if there is already a bom for this product
self.code = _("%s (new) %s", self.product_tmpl_id.name, number_of_bom_of_this_product)
else:
self.code = False
def copy(self, default=None):
res = super().copy(default)
for bom_line in res.bom_line_ids:
if bom_line.operation_id:
operation = res.operation_ids.filtered(lambda op: op.name == bom_line.operation_id.name and op.workcenter_id == bom_line.operation_id.workcenter_id)
bom_line.operation_id = operation
return res
@api.model
def name_create(self, name):
# prevent to use string as product_tmpl_id
if isinstance(name, str):
raise UserError(_("You cannot create a new Bill of Material from here."))
return super(MrpBom, self).name_create(name)
def toggle_active(self):
self.with_context({'active_test': False}).operation_ids.toggle_active()
return super().toggle_active()
def name_get(self):
return [(bom.id, '%s%s' % (bom.code and '%s: ' % bom.code or '', bom.product_tmpl_id.display_name)) for bom in self]
@api.constrains('product_tmpl_id', 'product_id', 'type')
def check_kit_has_not_orderpoint(self):
product_ids = [pid for bom in self.filtered(lambda bom: bom.type == "phantom")
for pid in (bom.product_id.ids or bom.product_tmpl_id.product_variant_ids.ids)]
if self.env['stock.warehouse.orderpoint'].search([('product_id', 'in', product_ids)], count=True):
raise ValidationError(_("You can not create a kit-type bill of materials for products that have at least one reordering rule."))
@api.ondelete(at_uninstall=False)
def _unlink_except_running_mo(self):
if self.env['mrp.production'].search([('bom_id', 'in', self.ids), ('state', 'not in', ['done', 'cancel'])], limit=1):
raise UserError(_('You can not delete a Bill of Material with running manufacturing orders.\nPlease close or cancel it first.'))
@api.model
def _name_search(self, name='', args=None, operator='ilike', limit=100, name_get_uid=None):
args = args or []
domain = []
if (name or '').strip():
domain = ['|', (self._rec_name, operator, name), ('code', operator, name)]
if operator in NEGATIVE_TERM_OPERATORS:
domain = domain[1:]
return self._search(AND([domain, args]), limit=limit, access_rights_uid=name_get_uid)
@api.model
def _bom_find_domain(self, products, picking_type=None, company_id=False, bom_type=False):
domain = ['|', ('product_id', 'in', products.ids), '&', ('product_id', '=', False), ('product_tmpl_id', 'in', products.product_tmpl_id.ids)]
if company_id or self.env.context.get('company_id'):
domain = AND([domain, ['|', ('company_id', '=', False), ('company_id', '=', company_id or self.env.context.get('company_id'))]])
if picking_type:
domain = AND([domain, ['|', ('picking_type_id', '=', picking_type.id), ('picking_type_id', '=', False)]])
if bom_type:
domain = AND([domain, [('type', '=', bom_type)]])
return domain
@api.model
def _bom_find(self, products, picking_type=None, company_id=False, bom_type=False):
""" Find the first BoM for each products
:param products: `product.product` recordset
:return: One bom (or empty recordset `mrp.bom` if none find) by product (`product.product` record)
:rtype: defaultdict(`lambda: self.env['mrp.bom']`)
"""
bom_by_product = defaultdict(lambda: self.env['mrp.bom'])
products = products.filtered(lambda p: p.type != 'service')
if not products:
return bom_by_product
domain = self._bom_find_domain(products, picking_type=picking_type, company_id=company_id, bom_type=bom_type)
# Performance optimization, allow usage of limit and avoid the for loop `bom.product_tmpl_id.product_variant_ids`
if len(products) == 1:
bom = self.search(domain, order='sequence, product_id, id', limit=1)
if bom:
bom_by_product[products] = bom
return bom_by_product
boms = self.search(domain, order='sequence, product_id, id')
products_ids = set(products.ids)
for bom in boms:
products_implies = bom.product_id or bom.product_tmpl_id.product_variant_ids
for product in products_implies:
if product.id in products_ids and product not in bom_by_product:
bom_by_product[product] = bom
return bom_by_product
def explode(self, product, quantity, picking_type=False):
"""
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] == False:
if check_cycle(neighbour, visited, recStack, graph) == True:
return True
elif recStack[neighbour] == 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._bom_find(products, picking_type=picking_type or self.picking_type_id,
company_id=self.company_id.id, bom_type='phantom'))
# 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 |= set([product.product_tmpl_id.id])
bom_lines = []
for bom_line in self.bom_line_ids:
product_id = bom_line.product_id
V |= set([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 not current_line.product_id in product_boms:
update_product_boms()
product_ids.clear()
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 |= set([bom_line.product_id.product_tmpl_id.id])
if not bom_line.product_id 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
@api.model
def get_import_templates(self):
return [{
'label': _('Import Template for Bills of Materials'),
'template': '/mrp/static/xls/mrp_bom.xls'
}]
class MrpBomLine(models.Model):
_name = 'mrp.bom.line'
_order = "sequence, id"
_rec_name = "product_id"
_description = 'Bill of Material Line'
_check_company_auto = True
def _get_default_product_uom_id(self):
return self.env['uom.uom'].search([], limit=1, order='id').id
product_id = fields.Many2one('product.product', 'Component', required=True, check_company=True)
product_tmpl_id = fields.Many2one('product.template', 'Product Template', related='product_id.product_tmpl_id', store=True, index=True)
company_id = fields.Many2one(
related='bom_id.company_id', store=True, index=True, readonly=True)
product_qty = fields.Float(
'Quantity', default=1.0,
digits='Product Unit of Measure', required=True)
product_uom_id = fields.Many2one(
'uom.uom', 'Product Unit of Measure',
default=_get_default_product_uom_id,
required=True,
help="Unit of Measure (Unit of Measure) is the unit of measurement for the inventory control", domain="[('category_id', '=', product_uom_category_id)]")
product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id')
sequence = fields.Integer(
'Sequence', default=1,
help="Gives the sequence order when displaying.")
bom_id = fields.Many2one(
'mrp.bom', 'Parent BoM',
index=True, ondelete='cascade', required=True)
parent_product_tmpl_id = fields.Many2one('product.template', 'Parent Product Template', related='bom_id.product_tmpl_id')
possible_bom_product_template_attribute_value_ids = fields.Many2many(related='bom_id.possible_product_template_attribute_value_ids')
bom_product_template_attribute_value_ids = fields.Many2many(
'product.template.attribute.value', string="Apply on Variants", ondelete='restrict',
domain="[('id', 'in', possible_bom_product_template_attribute_value_ids)]",
help="BOM Product Variants needed to apply this line.")
allowed_operation_ids = fields.One2many('mrp.routing.workcenter', related='bom_id.operation_ids')
operation_id = fields.Many2one(
'mrp.routing.workcenter', 'Consumed in Operation', check_company=True,
domain="[('id', 'in', allowed_operation_ids)]",
help="The operation where the components are consumed, or the finished products created.")
child_bom_id = fields.Many2one(
'mrp.bom', 'Sub BoM', compute='_compute_child_bom_id')
child_line_ids = fields.One2many(
'mrp.bom.line', string="BOM lines of the referred bom",
compute='_compute_child_line_ids')
attachments_count = fields.Integer('Attachments Count', compute='_compute_attachments_count')
_sql_constraints = [
('bom_qty_zero', 'CHECK (product_qty>=0)', 'All product quantities must be greater or equal to 0.\n'
'Lines with 0 quantities can be used as optional lines. \n'
'You should install the mrp_byproduct module if you want to manage extra products on BoMs !'),
]
@api.depends('product_id', 'bom_id')
def _compute_child_bom_id(self):
for line in self:
if not line.product_id:
line.child_bom_id = False
else:
line.child_bom_id = self.env['mrp.bom']._bom_find(line.product_id)[line.product_id]
@api.depends('product_id')
def _compute_attachments_count(self):
for line in self:
nbr_attach = self.env['mrp.document'].search_count([
'|',
'&', ('res_model', '=', 'product.product'), ('res_id', '=', line.product_id.id),
'&', ('res_model', '=', 'product.template'), ('res_id', '=', line.product_id.product_tmpl_id.id)])
line.attachments_count = nbr_attach
@api.depends('child_bom_id')
def _compute_child_line_ids(self):
""" If the BOM line refers to a BOM, return the ids of the child BOM lines """
for line in self:
line.child_line_ids = line.child_bom_id.bom_line_ids.ids or False
@api.onchange('product_uom_id')
def onchange_product_uom_id(self):
res = {}
if not self.product_uom_id or not self.product_id:
return res
if self.product_uom_id.category_id != self.product_id.uom_id.category_id:
self.product_uom_id = self.product_id.uom_id.id
res['warning'] = {'title': _('Warning'), 'message': _('The Product Unit of Measure you chose has a different category than in the product form.')}
return res
@api.onchange('product_id')
def onchange_product_id(self):
if self.product_id:
self.product_uom_id = self.product_id.uom_id.id
@api.model_create_multi
def create(self, vals_list):
for values in vals_list:
if 'product_id' in values and 'product_uom_id' not in values:
values['product_uom_id'] = self.env['product.product'].browse(values['product_id']).uom_id.id
return super(MrpBomLine, self).create(vals_list)
def _skip_bom_line(self, product):
""" Control if a BoM line should be produced, can be inherited to add
custom control.
"""
self.ensure_one()
if product._name == 'product.template':
return False
return not product._match_all_variant_values(self.bom_product_template_attribute_value_ids)
def action_see_attachments(self):
domain = [
'|',
'&', ('res_model', '=', 'product.product'), ('res_id', '=', self.product_id.id),
'&', ('res_model', '=', 'product.template'), ('res_id', '=', self.product_id.product_tmpl_id.id)]
attachment_view = self.env.ref('mrp.view_document_file_kanban_mrp')
return {
'name': _('Attachments'),
'domain': domain,
'res_model': 'mrp.document',
'type': 'ir.actions.act_window',
'view_id': attachment_view.id,
'views': [(attachment_view.id, 'kanban'), (False, 'form')],
'view_mode': 'kanban,tree,form',
'help': _('''<p class="o_view_nocontent_smiling_face">
Upload files to your product
</p><p>
Use this feature to store any files, like drawings or specifications.
</p>'''),
'limit': 80,
'context': "{'default_res_model': '%s','default_res_id': %d, 'default_company_id': %s}" % ('product.product', self.product_id.id, self.company_id.id)
}
class MrpByProduct(models.Model):
_name = 'mrp.bom.byproduct'
_description = 'Byproduct'
_rec_name = "product_id"
_check_company_auto = True
_order = 'sequence, id'
product_id = fields.Many2one('product.product', 'By-product', required=True, check_company=True)
company_id = fields.Many2one(related='bom_id.company_id', store=True, index=True, readonly=True)
product_qty = fields.Float(
'Quantity',
default=1.0, digits='Product Unit of Measure', required=True)
product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id')
product_uom_id = fields.Many2one('uom.uom', 'Unit of Measure', required=True,
domain="[('category_id', '=', product_uom_category_id)]")
bom_id = fields.Many2one('mrp.bom', 'BoM', ondelete='cascade', index=True)
allowed_operation_ids = fields.One2many('mrp.routing.workcenter', related='bom_id.operation_ids')
operation_id = fields.Many2one(
'mrp.routing.workcenter', 'Produced in Operation', check_company=True,
domain="[('id', 'in', allowed_operation_ids)]")
possible_bom_product_template_attribute_value_ids = fields.Many2many(related='bom_id.possible_product_template_attribute_value_ids')
bom_product_template_attribute_value_ids = fields.Many2many(
'product.template.attribute.value', string="Apply on Variants", ondelete='restrict',
domain="[('id', 'in', possible_bom_product_template_attribute_value_ids)]",
help="BOM Product Variants needed to apply this line.")
sequence = fields.Integer("Sequence")
cost_share = fields.Float(
"Cost Share (%)", digits=(5, 2), # decimal = 2 is important for rounding calculations!!
help="The percentage of the final production cost for this by-product line (divided between the quantity produced)."
"The total of all by-products' cost share must be less than or equal to 100.")
@api.onchange('product_id')
def _onchange_product_id(self):
""" Changes UoM if product_id changes. """
if self.product_id:
self.product_uom_id = self.product_id.uom_id.id
def _skip_byproduct_line(self, product):
""" Control if a byproduct line should be produced, can be inherited to add
custom control.
"""
self.ensure_one()
if product._name == 'product.template':
return False
return not product._match_all_variant_values(self.bom_product_template_attribute_value_ids)