diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/mrp/models/mrp_bom.py | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/mrp/models/mrp_bom.py')
| -rw-r--r-- | addons/mrp/models/mrp_bom.py | 520 |
1 files changed, 520 insertions, 0 deletions
diff --git a/addons/mrp/models/mrp_bom.py b/addons/mrp/models/mrp_bom.py new file mode 100644 index 00000000..0475d415 --- /dev/null +++ b/addons/mrp/models/mrp_bom.py @@ -0,0 +1,520 @@ +# -*- 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.tools import float_round + +from itertools import groupby +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" + _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='asap', 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 + ) + + _sql_constraints = [ + ('qty_positive', 'check (product_qty > 0)', 'The quantity to produce must be positive!'), + ] + + @api.onchange('product_id') + def onchange_product_id(self): + if self.product_id: + for line in self.bom_line_ids: + line.bom_product_template_attribute_value_ids = False + + @api.constrains('product_id', 'product_tmpl_id', 'bom_line_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) + if bom.product_id and bom_line.bom_product_template_attribute_value_ids: + raise ValidationError(_("BoM cannot concern product %s and have a line with attributes (%s) at the same time.") + % (bom.product_id.display_name, ", ".join([ptav.display_name for ptav in bom_line.bom_product_template_attribute_value_ids]))) + for ptav in bom_line.bom_product_template_attribute_value_ids: + 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 + )) + + @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 + for line in self.bom_line_ids: + line.bom_product_template_attribute_value_ids = 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 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.")) + + def unlink(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.')) + return super(MrpBom, self).unlink() + + @api.model + def _bom_find_domain(self, product_tmpl=None, product=None, picking_type=None, company_id=False, bom_type=False): + if product: + if not product_tmpl: + product_tmpl = product.product_tmpl_id + domain = ['|', ('product_id', '=', product.id), '&', ('product_id', '=', False), ('product_tmpl_id', '=', product_tmpl.id)] + elif product_tmpl: + domain = [('product_tmpl_id', '=', product_tmpl.id)] + else: + # neither product nor template, makes no sense to search + raise UserError(_('You should provide either a product or a product template to search a BoM')) + if picking_type: + domain += ['|', ('picking_type_id', '=', picking_type.id), ('picking_type_id', '=', False)] + if company_id or self.env.context.get('company_id'): + domain = domain + ['|', ('company_id', '=', False), ('company_id', '=', company_id or self.env.context.get('company_id'))] + if bom_type: + domain += [('type', '=', bom_type)] + # order to prioritize bom with product_id over the one without + return domain + + @api.model + def _bom_find(self, product_tmpl=None, product=None, picking_type=None, company_id=False, bom_type=False): + """ Finds BoM for particular product, picking and company """ + if product and product.type == 'service' or product_tmpl and product_tmpl.type == 'service': + return self.env['mrp.bom'] + domain = self._bom_find_domain(product_tmpl=product_tmpl, product=product, picking_type=picking_type, company_id=company_id, bom_type=bom_type) + if domain is False: + return self.env['mrp.bom'] + return self.search(domain, order='sequence, product_id', limit=1) + + @api.model + def _get_product2bom(self, products, bom_type=False, picking_type=False, company_id=False): + """Optimized variant of _bom_find to work with recordset""" + + bom_by_product = defaultdict(lambda: self.env['mrp.bom']) + products = products.filtered(lambda p: p.type != 'service') + if not products: + return bom_by_product + product_templates = products.mapped('product_tmpl_id') + domain = ['|', ('product_id', 'in', products.ids), '&', ('product_id', '=', False), ('product_tmpl_id', 'in', product_templates.ids)] + if picking_type: + domain += ['|', ('picking_type_id', '=', picking_type.id), ('picking_type_id', '=', False)] + if company_id or self.env.context.get('company_id'): + domain = domain + ['|', ('company_id', '=', False), ('company_id', '=', company_id or self.env.context.get('company_id'))] + if bom_type: + domain += [('type', '=', bom_type)] + + if len(products) == 1: + bom = self.search(domain, order='sequence, product_id', limit=1) + if bom: + bom_by_product[products] = bom + return bom_by_product + + boms = self.search(domain, order='sequence, product_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._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 |= 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') + 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('product.template.attribute.value', compute='_compute_possible_bom_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.Many2many('mrp.routing.workcenter', compute='_compute_allowed_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( + 'parent_product_tmpl_id.attribute_line_ids.value_ids', + 'parent_product_tmpl_id.attribute_line_ids.attribute_id.create_variant', + 'parent_product_tmpl_id.attribute_line_ids.product_template_value_ids.ptav_active', + ) + def _compute_possible_bom_product_template_attribute_value_ids(self): + for line in self: + line.possible_bom_product_template_attribute_value_ids = line.parent_product_tmpl_id.valid_product_template_attribute_line_ids._without_no_variant_attributes().product_template_value_ids._only_active() + + @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( + product_tmpl=line.product_id.product_tmpl_id, + product=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.depends('bom_id') + def _compute_allowed_operation_ids(self): + for bom_line in self: + if not bom_line.bom_id.operation_ids: + bom_line.allowed_operation_ids = self.env['mrp.routing.workcenter'] + else: + operation_domain = [ + ('id', 'in', bom_line.bom_id.operation_ids.ids), + '|', + ('company_id', '=', bom_line.company_id.id), + ('company_id', '=', False) + ] + bom_line.allowed_operation_ids = self.env['mrp.routing.workcenter'].search(operation_domain) + + @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. It currently checks that all variant values are in the + product. + + If multiple values are encoded for the same attribute line, only one of + them has to be found on the variant. + """ + self.ensure_one() + if product._name == 'product.template': + return False + if self.bom_product_template_attribute_value_ids: + for ptal, iter_ptav in groupby(self.bom_product_template_attribute_value_ids.sorted('attribute_line_id'), lambda ptav: ptav.attribute_line_id): + if not any(ptav in product.product_template_attribute_value_ids for ptav in iter_ptav): + return True + return False + + 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 + + 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_id = fields.Many2one('uom.uom', 'Unit of Measure', required=True) + bom_id = fields.Many2one('mrp.bom', 'BoM', ondelete='cascade') + allowed_operation_ids = fields.Many2many('mrp.routing.workcenter', compute='_compute_allowed_operation_ids') + operation_id = fields.Many2one( + 'mrp.routing.workcenter', 'Produced in Operation', check_company=True, + domain="[('id', 'in', allowed_operation_ids)]") + + @api.depends('bom_id') + def _compute_allowed_operation_ids(self): + for byproduct in self: + if not byproduct.bom_id.operation_ids: + byproduct.allowed_operation_ids = self.env['mrp.routing.workcenter'] + else: + operation_domain = [ + ('id', 'in', byproduct.bom_id.operation_ids.ids), + '|', + ('company_id', '=', byproduct.company_id.id), + ('company_id', '=', False) + ] + byproduct.allowed_operation_ids = self.env['mrp.routing.workcenter'].search(operation_domain) + + @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 + + @api.onchange('product_uom_id') + def onchange_uom(self): + res = {} + if self.product_uom_id and self.product_id and self.product_uom_id.category_id != self.product_id.uom_id.category_id: + res['warning'] = { + 'title': _('Warning'), + 'message': _('The unit of measure you choose is in a different category than the product unit of measure.') + } + self.product_uom_id = self.product_id.uom_id.id + return res |
