Initial commit: Mrp packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:50 +02:00
commit 50d736b3bd
739 changed files with 538193 additions and 0 deletions

View file

@ -0,0 +1,22 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import mrp_document
from . import res_config_settings
from . import mrp_bom
from . import mrp_routing
from . import mrp_workcenter
from . import mrp_production
from . import stock_traceability
from . import mrp_unbuild
from . import mrp_workorder
from . import product
from . import res_company
from . import stock_move
from . import stock_orderpoint
from . import stock_picking
from . import stock_lot
from . import stock_rule
from . import stock_scrap
from . import stock_warehouse
from . import stock_quant

View file

@ -0,0 +1,603 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _, Command
from odoo.exceptions import UserError, ValidationError
from odoo.osv.expression import AND, OR
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'
_rec_names_search = ['product_tmpl_id', 'code']
_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)
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='Product Unit of Measure', required=True,
help="This should be the smallest quantity that this product can be produced in. If the BOM contains operations, make sure the work center capacity is accurate.")
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')
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', 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"
" Note that in the case of component Manual Consumption, where consumption is registered manually exclusively, consumption warnings will still be issued when appropriate also.\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')
allow_operation_dependencies = fields.Boolean('Operation Dependencies',
help="Create operation level dependencies that will influence both planning and the status of work orders upon MO confirmation. If this feature is ticked, and nothing is specified, Odoo will assume that all operations can be started simultaneously."
)
_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('active', 'product_id', 'product_tmpl_id', 'bom_line_ids')
def _check_bom_cycle(self):
subcomponents_dict = dict()
def _check_cycle(components, finished_products):
"""
Check whether the components are part of the finished products (-> cycle). Then, if
these components have a BoM, repeat the operation with the subcomponents (recursion).
The method will return the list of product variants that creates the cycle
"""
products_to_find = self.env['product.product']
for component in components:
if component in finished_products:
names = finished_products.mapped('display_name')
raise ValidationError(_("The current configuration is incorrect because it would create a cycle "
"between these products: %s.") % ', '.join(names))
if component not in subcomponents_dict:
products_to_find |= component
bom_find_result = self._bom_find(products_to_find)
for component in components:
if component not in subcomponents_dict:
bom = bom_find_result[component]
subcomponents = bom.bom_line_ids.filtered(lambda l: not l._skip_bom_line(component)).product_id
subcomponents_dict[component] = subcomponents
subcomponents = subcomponents_dict[component]
if subcomponents:
_check_cycle(subcomponents, finished_products | component)
boms_to_check = self
domain = []
for product in self.bom_line_ids.product_id:
domain = OR([domain, self._bom_find_domain(product)])
if domain:
boms_to_check |= self.env['mrp.bom'].search(domain)
for bom in boms_to_check:
if not bom.active:
continue
finished_products = bom.product_id or bom.product_tmpl_id.product_variant_ids
if bom.bom_line_ids.bom_product_template_attribute_value_ids:
grouped_by_components = defaultdict(lambda: self.env['product.product'])
for finished in finished_products:
components = bom.bom_line_ids.filtered(lambda l: not l._skip_bom_line(finished)).product_id
grouped_by_components[components] |= finished
for components, finished in grouped_by_components.items():
_check_cycle(components, finished)
else:
_check_cycle(bom.bom_line_ids.product_id, finished_products)
def write(self, vals):
res = super().write(vals)
if 'sequence' in vals and self and self[-1].id == self._prefetch_ids[-1]:
self.browse(self._prefetch_ids)._check_bom_cycle()
return res
@api.constrains('product_id', 'product_tmpl_id', 'bom_line_ids', 'byproduct_ids', 'operation_ids')
def _check_bom_lines(self):
for bom in self:
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.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('bom_line_ids', 'product_qty', 'product_id', 'product_tmpl_id')
def onchange_bom_structure(self):
if self.type == 'phantom' and self._origin and self.env['stock.move'].search([('bom_line_id', 'in', self._origin.bom_line_ids.ids)], limit=1):
return {
'warning': {
'title': _('Warning'),
'message': _(
'The product has already been used at least once, editing its structure may lead to undesirable behaviours. '
'You should rather archive the product and create a new one with a new bill of materials.'),
}
}
@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)
if self.operation_ids:
operations_mapping = {}
for original, copied in zip(self.operation_ids, res.operation_ids.sorted()):
operations_mapping[original] = copied
for bom_line in res.bom_line_ids:
if bom_line.operation_id:
bom_line.operation_id = operations_mapping[bom_line.operation_id]
for byproduct in res.byproduct_ids:
if byproduct.operation_id:
byproduct.operation_id = operations_mapping[byproduct.operation_id]
for operation in self.operation_ids:
if operation.blocked_by_operation_ids:
copied_operation = operations_mapping[operation]
dependencies = []
for dependency in operation.blocked_by_operation_ids:
dependencies.append(Command.link(operations_mapping[dependency].id))
copied_operation.blocked_by_operation_ids = dependencies
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 _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), ('active', '=', True)]
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')
tracking = fields.Selection(related='product_id.tracking')
manual_consumption = fields.Boolean(
'Manual Consumption', default=False, compute='_compute_manual_consumption',
readonly=False, store=True, copy=True,
help="When activated, then the registration of consumption for that component is recorded manually exclusively.\n"
"If not activated, and any of the components consumption is edited manually on the manufacturing order, Odoo assumes manual consumption also.")
manual_consumption_readonly = fields.Boolean(
'Manual Consumption Readonly', compute='_compute_manual_consumption_readonly')
_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', 'tracking', 'operation_id')
def _compute_manual_consumption(self):
for line in self:
line.manual_consumption = (line.tracking != 'none' or line.operation_id)
@api.depends('tracking', 'operation_id')
def _compute_manual_consumption_readonly(self):
for line in self:
line.manual_consumption_readonly = (line.tracking != 'none' or line.operation_id)
@api.depends('product_id', 'bom_id')
def _compute_child_bom_id(self):
products = self.product_id
bom_by_product = self.env['mrp.bom']._bom_find(products)
for line in self:
if not line.product_id:
line.child_bom_id = False
else:
line.child_bom_id = bom_by_product.get(line.product_id, False)
@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,
compute="_compute_product_uom_id", store=True, readonly=False, precompute=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.depends('product_id')
def _compute_product_uom_id(self):
""" Changes UoM if product_id changes. """
for record in self:
record.product_uom_id = record.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)

View file

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class MrpDocument(models.Model):
""" Extension of ir.attachment only used in MRP to handle archivage
and basic versioning.
"""
_name = 'mrp.document'
_description = "Production Document"
_inherits = {
'ir.attachment': 'ir_attachment_id',
}
_order = "priority desc, id desc"
def copy(self, default=None):
ir_default = default
if ir_default:
ir_fields = list(self.env['ir.attachment']._fields)
ir_default = {field : default[field] for field in default.keys() if field in ir_fields}
new_attach = self.ir_attachment_id.with_context(no_document=True).copy(ir_default)
return super().copy(dict(default, ir_attachment_id=new_attach.id))
ir_attachment_id = fields.Many2one('ir.attachment', string='Related attachment', required=True, ondelete='cascade')
active = fields.Boolean('Active', default=True)
priority = fields.Selection([
('0', 'Normal'),
('1', 'Low'),
('2', 'High'),
('3', 'Very High')], string="Priority") # used to order
def unlink(self):
self.mapped('ir_attachment_id').unlink()
return super(MrpDocument, self).unlink()

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,167 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _, tools
from odoo.exceptions import ValidationError
class MrpRoutingWorkcenter(models.Model):
_name = 'mrp.routing.workcenter'
_description = 'Work Center Usage'
_order = 'bom_id, sequence, id'
_check_company_auto = True
name = fields.Char('Operation', required=True)
active = fields.Boolean(default=True)
workcenter_id = fields.Many2one('mrp.workcenter', 'Work Center', required=True, check_company=True)
sequence = fields.Integer(
'Sequence', default=100,
help="Gives the sequence order when displaying a list of routing Work Centers.")
bom_id = fields.Many2one(
'mrp.bom', 'Bill of Material',
index=True, ondelete='cascade', required=True, check_company=True)
company_id = fields.Many2one('res.company', 'Company', related='bom_id.company_id')
worksheet_type = fields.Selection([
('pdf', 'PDF'), ('google_slide', 'Google Slide'), ('text', 'Text')],
string="Worksheet", default="text"
)
note = fields.Html('Description')
worksheet = fields.Binary('PDF')
worksheet_google_slide = fields.Char('Google Slide', help="Paste the url of your Google Slide. Make sure the access to the document is public.")
time_mode = fields.Selection([
('auto', 'Compute based on tracked time'),
('manual', 'Set duration manually')], string='Duration Computation',
default='manual')
time_mode_batch = fields.Integer('Based on', default=10)
time_computed_on = fields.Char('Computed on last', compute='_compute_time_computed_on')
time_cycle_manual = fields.Float(
'Manual Duration', default=60,
help="Time in minutes:"
"- In manual mode, time used"
"- In automatic mode, supposed first time when there aren't any work orders yet")
time_cycle = fields.Float('Duration', compute="_compute_time_cycle")
workorder_count = fields.Integer("# Work Orders", compute="_compute_workorder_count")
workorder_ids = fields.One2many('mrp.workorder', 'operation_id', string="Work Orders")
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.")
allow_operation_dependencies = fields.Boolean(related='bom_id.allow_operation_dependencies')
blocked_by_operation_ids = fields.Many2many('mrp.routing.workcenter', relation="mrp_routing_workcenter_dependencies_rel",
column1="operation_id", column2="blocked_by_id",
string="Blocked By", help="Operations that need to be completed before this operation can start.",
domain="[('allow_operation_dependencies', '=', True), ('id', '!=', id), ('bom_id', '=', bom_id)]",
copy=False)
needed_by_operation_ids = fields.Many2many('mrp.routing.workcenter', relation="mrp_routing_workcenter_dependencies_rel",
column1="blocked_by_id", column2="operation_id",
string="Blocks", help="Operations that cannot start before this operation is completed.",
domain="[('allow_operation_dependencies', '=', True), ('id', '!=', id), ('bom_id', '=', bom_id)]",
copy=False)
@api.depends('time_mode', 'time_mode_batch')
def _compute_time_computed_on(self):
for operation in self:
operation.time_computed_on = _('%i work orders') % operation.time_mode_batch if operation.time_mode != 'manual' else False
@api.depends('time_cycle_manual', 'time_mode', 'workorder_ids')
def _compute_time_cycle(self):
manual_ops = self.filtered(lambda operation: operation.time_mode == 'manual')
for operation in manual_ops:
operation.time_cycle = operation.time_cycle_manual
for operation in self - manual_ops:
data = self.env['mrp.workorder'].search([
('operation_id', '=', operation.id),
('qty_produced', '>', 0),
('state', '=', 'done')],
limit=operation.time_mode_batch,
order="date_finished desc, id desc")
# To compute the time_cycle, we can take the total duration of previous operations
# but for the quantity, we will take in consideration the qty_produced like if the capacity was 1.
# So producing 50 in 00:10 with capacity 2, for the time_cycle, we assume it is 25 in 00:10
# When recomputing the expected duration, the capacity is used again to divide the qty to produce
# so that if we need 50 with capacity 2, it will compute the expected of 25 which is 00:10
total_duration = 0 # Can be 0 since it's not an invalid duration for BoM
cycle_number = 0 # Never 0 unless infinite item['workcenter_id'].capacity
for item in data:
total_duration += item['duration']
capacity = item['workcenter_id']._get_capacity(item.product_id)
qty_produced = item.product_uom_id._compute_quantity(item['qty_produced'], item.product_id.uom_id)
cycle_number += tools.float_round((qty_produced / capacity or 1.0), precision_digits=0, rounding_method='UP')
if cycle_number:
operation.time_cycle = total_duration / cycle_number
else:
operation.time_cycle = operation.time_cycle_manual
def _compute_workorder_count(self):
data = self.env['mrp.workorder']._read_group([
('operation_id', 'in', self.ids),
('state', '=', 'done')], ['operation_id'], ['operation_id'])
count_data = dict((item['operation_id'][0], item['operation_id_count']) for item in data)
for operation in self:
operation.workorder_count = count_data.get(operation.id, 0)
@api.constrains('blocked_by_operation_ids')
def _check_no_cyclic_dependencies(self):
if not self._check_m2m_recursion('blocked_by_operation_ids'):
raise ValidationError(_("You cannot create cyclic dependency."))
def action_archive(self):
res = super().action_archive()
bom_lines = self.env['mrp.bom.line'].search([('operation_id', 'in', self.ids)])
bom_lines.write({'operation_id': False})
byproduct_lines = self.env['mrp.bom.byproduct'].search([('operation_id', 'in', self.ids)])
byproduct_lines.write({'operation_id': False})
return res
def copy_to_bom(self):
if 'bom_id' in self.env.context:
bom_id = self.env.context.get('bom_id')
for operation in self:
operation.copy({'bom_id': bom_id})
return {
'view_mode': 'form',
'res_model': 'mrp.bom',
'views': [(False, 'form')],
'type': 'ir.actions.act_window',
'res_id': bom_id,
}
def copy_existing_operations(self):
return {
'type': 'ir.actions.act_window',
'name': _('Select Operations to Copy'),
'res_model': 'mrp.routing.workcenter',
'view_mode': 'tree,form',
'domain': ['|', ('bom_id', '=', False), ('bom_id.active', '=', True)],
'context' : {
'bom_id': self.env.context["bom_id"],
'tree_view_ref': 'mrp.mrp_routing_workcenter_copy_to_bom_tree_view',
}
}
def _skip_operation_line(self, product):
""" Control if a operation should be processed, can be inherited to add
custom control.
"""
self.ensure_one()
# skip operation line if archived
if not self.active:
return True
if product._name == 'product.template':
return False
return not product._match_all_variant_values(self.bom_product_template_attribute_value_ids)
def _get_comparison_values(self):
if not self:
return False
self.ensure_one()
return tuple(self[key] for key in ('name', 'company_id', 'workcenter_id', 'time_mode', 'time_cycle_manual', 'bom_product_template_attribute_value_ids'))
def write(self, values):
if 'bom_id' in values:
for op in self:
op.bom_id.bom_line_ids.filtered(lambda line: line.operation_id == op).operation_id = False
op.bom_id.byproduct_ids.filtered(lambda byproduct: byproduct.operation_id == op).operation_id = False
op.bom_id.operation_ids.filtered(lambda operation: operation.blocked_by_operation_ids == op).blocked_by_operation_ids = False
return super().write(values)

View file

@ -0,0 +1,319 @@
# -*- 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_compare, float_round
from odoo.osv import expression
from collections import defaultdict
class MrpUnbuild(models.Model):
_name = "mrp.unbuild"
_description = "Unbuild Order"
_inherit = ['mail.thread', 'mail.activity.mixin']
_order = 'id desc'
name = fields.Char('Reference', copy=False, readonly=True, default=lambda x: _('New'))
product_id = fields.Many2one(
'product.product', 'Product', check_company=True,
domain="[('type', 'in', ['product', 'consu']), '|', ('company_id', '=', False), ('company_id', '=', company_id)]",
required=True, states={'done': [('readonly', True)]})
company_id = fields.Many2one(
'res.company', 'Company',
default=lambda s: s.env.company,
required=True, index=True, states={'done': [('readonly', True)]})
product_qty = fields.Float(
'Quantity', default=1.0,
required=True, states={'done': [('readonly', True)]})
product_uom_id = fields.Many2one(
'uom.uom', 'Unit of Measure',
compute='_compute_product_uom_id', store=True, readonly=False, precompute=True,
required=True, states={'done': [('readonly', True)]})
bom_id = fields.Many2one(
'mrp.bom', 'Bill of Material',
domain="""[
'|',
('product_id', '=', product_id),
'&',
('product_tmpl_id.product_variant_ids', '=', product_id),
('product_id','=',False),
('type', '=', 'normal'),
'|',
('company_id', '=', company_id),
('company_id', '=', False)
]
""",
states={'done': [('readonly', True)]}, check_company=True)
mo_id = fields.Many2one(
'mrp.production', 'Manufacturing Order',
domain="[('state', '=', 'done'), ('company_id', '=', company_id), ('product_id', '=?', product_id), ('bom_id', '=?', bom_id)]",
states={'done': [('readonly', True)]}, check_company=True)
mo_bom_id = fields.Many2one('mrp.bom', 'Bill of Material used on the Production Order', related='mo_id.bom_id')
lot_id = fields.Many2one(
'stock.lot', 'Lot/Serial Number',
domain="[('product_id', '=', product_id), ('company_id', '=', company_id)]", check_company=True)
has_tracking=fields.Selection(related='product_id.tracking', readonly=True)
location_id = fields.Many2one(
'stock.location', 'Source Location',
domain="[('usage','=','internal'), '|', ('company_id', '=', False), ('company_id', '=', company_id)]",
check_company=True,
compute='_compute_location_id', store=True, readonly=False, precompute=True,
required=True, states={'done': [('readonly', True)]}, help="Location where the product you want to unbuild is.")
location_dest_id = fields.Many2one(
'stock.location', 'Destination Location',
domain="[('usage','=','internal'), '|', ('company_id', '=', False), ('company_id', '=', company_id)]",
check_company=True,
compute='_compute_location_id', store=True, readonly=False, precompute=True,
required=True, states={'done': [('readonly', True)]}, help="Location where you want to send the components resulting from the unbuild order.")
consume_line_ids = fields.One2many(
'stock.move', 'consume_unbuild_id', readonly=True,
string='Consumed Disassembly Lines')
produce_line_ids = fields.One2many(
'stock.move', 'unbuild_id', readonly=True,
string='Processed Disassembly Lines')
state = fields.Selection([
('draft', 'Draft'),
('done', 'Done')], string='Status', default='draft')
@api.depends('mo_id', 'product_id')
def _compute_product_uom_id(self):
for record in self:
if record.mo_id.product_id and record.mo_id.product_id == record.product_id:
record.product_uom_id = record.mo_id.product_uom_id.id
else:
record.product_uom_id = record.product_id.uom_id.id
@api.depends('company_id')
def _compute_location_id(self):
for order in self:
if order.company_id:
warehouse = self.env['stock.warehouse'].search([('company_id', '=', order.company_id.id)], limit=1)
if order.location_id.company_id != order.company_id:
order.location_id = warehouse.lot_stock_id
if order.location_dest_id.company_id != order.company_id:
order.location_dest_id = warehouse.lot_stock_id
@api.onchange('mo_id')
def _onchange_mo_id(self):
if self.mo_id:
self.product_id = self.mo_id.product_id.id
self.bom_id = self.mo_id.bom_id
self.product_uom_id = self.mo_id.product_uom_id
self.lot_id = self.mo_id.lot_producing_id
if self.has_tracking == 'serial':
self.product_qty = 1
else:
self.product_qty = self.mo_id.qty_produced
@api.onchange('product_id')
def _onchange_product_id(self):
if self.product_id:
self.bom_id = self.env['mrp.bom']._bom_find(self.product_id, company_id=self.company_id.id)[self.product_id]
self.product_uom_id = self.mo_id.product_id == self.product_id and self.mo_id.product_uom_id.id or self.product_id.uom_id.id
@api.constrains('product_qty')
def _check_qty(self):
for unbuild in self:
if unbuild.product_qty <= 0:
raise ValidationError(_('Unbuild Order product quantity has to be strictly positive.'))
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if not vals.get('name') or vals['name'] == _('New'):
vals['name'] = self.env['ir.sequence'].next_by_code('mrp.unbuild') or _('New')
return super().create(vals_list)
@api.ondelete(at_uninstall=False)
def _unlink_except_done(self):
if 'done' in self.mapped('state'):
raise UserError(_("You cannot delete an unbuild order if the state is 'Done'."))
def _prepare_finished_move_line_vals(self, finished_move):
return {
'move_id': finished_move.id,
'lot_id': self.lot_id.id,
'qty_done': finished_move.product_uom_qty,
'product_id': finished_move.product_id.id,
'product_uom_id': finished_move.product_uom.id,
'location_id': finished_move.location_id.id,
'location_dest_id': finished_move.location_dest_id.id,
}
def _prepare_move_line_vals(self, move, origin_move_line, taken_quantity):
return {
'move_id': move.id,
'lot_id': origin_move_line.lot_id.id,
'qty_done': taken_quantity,
'product_id': move.product_id.id,
'product_uom_id': origin_move_line.product_uom_id.id,
'location_id': move.location_id.id,
'location_dest_id': move.location_dest_id.id,
}
def action_unbuild(self):
self.ensure_one()
self._check_company()
if self.product_id.tracking != 'none' and not self.lot_id.id:
raise UserError(_('You should provide a lot number for the final product.'))
if self.mo_id:
if self.mo_id.state != 'done':
raise UserError(_('You cannot unbuild a undone manufacturing order.'))
consume_moves = self._generate_consume_moves()
consume_moves._action_confirm()
produce_moves = self._generate_produce_moves()
produce_moves.with_context(default_lot_id=False)._action_confirm()
finished_moves = consume_moves.filtered(lambda m: m.product_id == self.product_id)
consume_moves -= finished_moves
if any(produce_move.has_tracking != 'none' and not self.mo_id for produce_move in produce_moves):
raise UserError(_('Some of your components are tracked, you have to specify a manufacturing order in order to retrieve the correct components.'))
if any(consume_move.has_tracking != 'none' and not self.mo_id for consume_move in consume_moves):
raise UserError(_('Some of your byproducts are tracked, you have to specify a manufacturing order in order to retrieve the correct byproducts.'))
for finished_move in finished_moves:
if finished_move.has_tracking != 'none':
finished_move_line_vals = self._prepare_finished_move_line_vals(finished_move)
self.env["stock.move.line"].create(finished_move_line_vals)
else:
finished_move.quantity_done = self.product_qty
# TODO: Will fail if user do more than one unbuild with lot on the same MO. Need to check what other unbuild has aready took
qty_already_used = defaultdict(float)
for move in produce_moves | consume_moves:
if move.has_tracking != 'none':
original_move = move in produce_moves and self.mo_id.move_raw_ids or self.mo_id.move_finished_ids
original_move = original_move.filtered(lambda m: m.product_id == move.product_id)
needed_quantity = move.product_uom_qty
moves_lines = original_move.mapped('move_line_ids')
if move in produce_moves and self.lot_id:
moves_lines = moves_lines.filtered(lambda ml: self.lot_id in ml.produce_line_ids.lot_id) # FIXME sle: double check with arm
for move_line in moves_lines:
# Iterate over all move_lines until we unbuilded the correct quantity.
taken_quantity = min(needed_quantity, move_line.qty_done - qty_already_used[move_line])
if taken_quantity:
move_line_vals = self._prepare_move_line_vals(move, move_line, taken_quantity)
self.env["stock.move.line"].create(move_line_vals)
needed_quantity -= taken_quantity
qty_already_used[move_line] += taken_quantity
else:
move.quantity_done = float_round(move.product_uom_qty, precision_rounding=move.product_uom.rounding)
finished_moves._action_done()
consume_moves._action_done()
produce_moves._action_done()
produced_move_line_ids = produce_moves.mapped('move_line_ids').filtered(lambda ml: ml.qty_done > 0)
consume_moves.mapped('move_line_ids').write({'produce_line_ids': [(6, 0, produced_move_line_ids.ids)]})
if self.mo_id:
unbuild_msg = _(
"%(qty)s %(measure)s unbuilt in %(order)s",
qty=self.product_qty,
measure=self.product_uom_id.name,
order=self._get_html_link(),
)
self.mo_id.message_post(
body=unbuild_msg,
subtype_id=self.env.ref('mail.mt_note').id)
return self.write({'state': 'done'})
def _generate_consume_moves(self):
moves = self.env['stock.move']
for unbuild in self:
if unbuild.mo_id:
finished_moves = unbuild.mo_id.move_finished_ids.filtered(lambda move: move.state == 'done')
moved_qty = unbuild.mo_id.product_uom_id._compute_quantity(unbuild.mo_id.qty_produced, unbuild.product_uom_id)
factor = unbuild.product_qty / moved_qty if moved_qty else 0
for finished_move in finished_moves:
moves += unbuild._generate_move_from_existing_move(finished_move, factor, unbuild.location_id, finished_move.location_id)
else:
factor = unbuild.product_uom_id._compute_quantity(unbuild.product_qty, unbuild.bom_id.product_uom_id) / unbuild.bom_id.product_qty
moves += unbuild._generate_move_from_bom_line(self.product_id, self.product_uom_id, unbuild.product_qty)
for byproduct in unbuild.bom_id.byproduct_ids:
if byproduct._skip_byproduct_line(unbuild.product_id):
continue
quantity = byproduct.product_qty * factor
moves += unbuild._generate_move_from_bom_line(byproduct.product_id, byproduct.product_uom_id, quantity, byproduct_id=byproduct.id)
return moves
def _generate_produce_moves(self):
moves = self.env['stock.move']
for unbuild in self:
if unbuild.mo_id:
raw_moves = unbuild.mo_id.move_raw_ids.filtered(lambda move: move.state == 'done')
factor = unbuild.product_qty / unbuild.mo_id.product_uom_id._compute_quantity(unbuild.mo_id.qty_produced, unbuild.product_uom_id)
for raw_move in raw_moves:
moves += unbuild._generate_move_from_existing_move(raw_move, factor, raw_move.location_dest_id, self.location_dest_id)
else:
factor = unbuild.product_uom_id._compute_quantity(unbuild.product_qty, unbuild.bom_id.product_uom_id) / unbuild.bom_id.product_qty
boms, lines = unbuild.bom_id.explode(unbuild.product_id, factor, picking_type=unbuild.bom_id.picking_type_id)
for line, line_data in lines:
moves += unbuild._generate_move_from_bom_line(line.product_id, line.product_uom_id, line_data['qty'], bom_line_id=line.id)
return moves
def _generate_move_from_existing_move(self, move, factor, location_id, location_dest_id):
return self.env['stock.move'].create({
'name': self.name,
'date': self.create_date,
'product_id': move.product_id.id,
'product_uom_qty': move.quantity_done * factor,
'product_uom': move.product_uom.id,
'procure_method': 'make_to_stock',
'location_dest_id': location_dest_id.id,
'location_id': location_id.id,
'warehouse_id': location_dest_id.warehouse_id.id,
'unbuild_id': self.id,
'company_id': move.company_id.id,
'origin_returned_move_id': move.id,
})
def _generate_move_from_bom_line(self, product, product_uom, quantity, bom_line_id=False, byproduct_id=False):
product_prod_location = product.with_company(self.company_id).property_stock_production
location_id = bom_line_id and product_prod_location or self.location_id
location_dest_id = bom_line_id and self.location_dest_id or product_prod_location
warehouse = location_dest_id.warehouse_id
return self.env['stock.move'].create({
'name': self.name,
'date': self.create_date,
'bom_line_id': bom_line_id,
'byproduct_id': byproduct_id,
'product_id': product.id,
'product_uom_qty': quantity,
'product_uom': product_uom.id,
'procure_method': 'make_to_stock',
'location_dest_id': location_dest_id.id,
'location_id': location_id.id,
'warehouse_id': warehouse.id,
'unbuild_id': self.id,
'company_id': self.company_id.id,
})
def action_validate(self):
self.ensure_one()
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
available_qty = self.env['stock.quant']._get_available_quantity(self.product_id, self.location_id, self.lot_id, strict=True)
unbuild_qty = self.product_uom_id._compute_quantity(self.product_qty, self.product_id.uom_id)
if float_compare(available_qty, unbuild_qty, precision_digits=precision) >= 0:
return self.action_unbuild()
else:
return {
'name': self.product_id.display_name + _(': Insufficient Quantity To Unbuild'),
'view_mode': 'form',
'res_model': 'stock.warn.insufficient.qty.unbuild',
'view_id': self.env.ref('mrp.stock_warn_insufficient_qty_unbuild_form_view').id,
'type': 'ir.actions.act_window',
'context': {
'default_product_id': self.product_id.id,
'default_location_id': self.location_id.id,
'default_unbuild_id': self.id,
'default_quantity': unbuild_qty,
'default_product_uom_name': self.product_id.uom_name
},
'target': 'new'
}

View file

@ -0,0 +1,464 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from dateutil import relativedelta
from datetime import timedelta, datetime
from functools import partial
from pytz import timezone
from random import randint
from odoo import api, exceptions, fields, models, _
from odoo.exceptions import UserError, ValidationError
from odoo.addons.resource.models.resource import make_aware, Intervals
from odoo.tools.float_utils import float_compare
class MrpWorkcenter(models.Model):
_name = 'mrp.workcenter'
_description = 'Work Center'
_order = "sequence, id"
_inherit = ['resource.mixin']
_check_company_auto = True
# resource
name = fields.Char('Work Center', related='resource_id.name', store=True, readonly=False)
time_efficiency = fields.Float('Time Efficiency', related='resource_id.time_efficiency', default=100, store=True, readonly=False)
active = fields.Boolean('Active', related='resource_id.active', default=True, store=True, readonly=False)
code = fields.Char('Code', copy=False)
note = fields.Html(
'Description')
default_capacity = fields.Float(
'Capacity', default=1.0,
help="Default number of pieces (in product UoM) that can be produced in parallel (at the same time) at this work center. For example: the capacity is 5 and you need to produce 10 units, then the operation time listed on the BOM will be multiplied by two. However, note that both time before and after production will only be counted once.")
sequence = fields.Integer(
'Sequence', default=1, required=True,
help="Gives the sequence order when displaying a list of work centers.")
color = fields.Integer('Color')
currency_id = fields.Many2one('res.currency', 'Currency', related='company_id.currency_id', readonly=True, required=True)
costs_hour = fields.Float(string='Cost per hour', help='Hourly processing cost.', default=0.0)
time_start = fields.Float('Setup Time')
time_stop = fields.Float('Cleanup Time')
routing_line_ids = fields.One2many('mrp.routing.workcenter', 'workcenter_id', "Routing Lines")
order_ids = fields.One2many('mrp.workorder', 'workcenter_id', "Orders")
workorder_count = fields.Integer('# Work Orders', compute='_compute_workorder_count')
workorder_ready_count = fields.Integer('# Read Work Orders', compute='_compute_workorder_count')
workorder_progress_count = fields.Integer('Total Running Orders', compute='_compute_workorder_count')
workorder_pending_count = fields.Integer('Total Pending Orders', compute='_compute_workorder_count')
workorder_late_count = fields.Integer('Total Late Orders', compute='_compute_workorder_count')
time_ids = fields.One2many('mrp.workcenter.productivity', 'workcenter_id', 'Time Logs')
working_state = fields.Selection([
('normal', 'Normal'),
('blocked', 'Blocked'),
('done', 'In Progress')], 'Workcenter Status', compute="_compute_working_state", store=True)
blocked_time = fields.Float(
'Blocked Time', compute='_compute_blocked_time',
help='Blocked hours over the last month', digits=(16, 2))
productive_time = fields.Float(
'Productive Time', compute='_compute_productive_time',
help='Productive hours over the last month', digits=(16, 2))
oee = fields.Float(compute='_compute_oee', help='Overall Equipment Effectiveness, based on the last month')
oee_target = fields.Float(string='OEE Target', help="Overall Effective Efficiency Target in percentage", default=90)
performance = fields.Integer('Performance', compute='_compute_performance', help='Performance over the last month')
workcenter_load = fields.Float('Work Center Load', compute='_compute_workorder_count')
alternative_workcenter_ids = fields.Many2many(
'mrp.workcenter',
'mrp_workcenter_alternative_rel',
'workcenter_id',
'alternative_workcenter_id',
domain="[('id', '!=', id), '|', ('company_id', '=', company_id), ('company_id', '=', False)]",
string="Alternative Workcenters", check_company=True,
help="Alternative workcenters that can be substituted to this one in order to dispatch production"
)
tag_ids = fields.Many2many('mrp.workcenter.tag')
capacity_ids = fields.One2many('mrp.workcenter.capacity', 'workcenter_id', string='Product Capacities',
help="Specific number of pieces that can be produced in parallel per product.", copy=True)
@api.constrains('alternative_workcenter_ids')
def _check_alternative_workcenter(self):
for workcenter in self:
if workcenter in workcenter.alternative_workcenter_ids:
raise ValidationError(_("Workcenter %s cannot be an alternative of itself.", workcenter.name))
@api.depends('order_ids.duration_expected', 'order_ids.workcenter_id', 'order_ids.state', 'order_ids.date_planned_start')
def _compute_workorder_count(self):
MrpWorkorder = self.env['mrp.workorder']
result = {wid: {} for wid in self._ids}
result_duration_expected = {wid: 0 for wid in self._ids}
# Count Late Workorder
data = MrpWorkorder._read_group(
[('workcenter_id', 'in', self.ids), ('state', 'in', ('pending', 'waiting', 'ready')), ('date_planned_start', '<', datetime.now().strftime('%Y-%m-%d'))],
['workcenter_id'], ['workcenter_id'])
count_data = dict((item['workcenter_id'][0], item['workcenter_id_count']) for item in data)
# Count All, Pending, Ready, Progress Workorder
res = MrpWorkorder._read_group(
[('workcenter_id', 'in', self.ids)],
['workcenter_id', 'state', 'duration_expected'], ['workcenter_id', 'state'],
lazy=False)
for res_group in res:
result[res_group['workcenter_id'][0]][res_group['state']] = res_group['__count']
if res_group['state'] in ('pending', 'waiting', 'ready', 'progress'):
result_duration_expected[res_group['workcenter_id'][0]] += res_group['duration_expected']
for workcenter in self:
workcenter.workorder_count = sum(count for state, count in result[workcenter.id].items() if state not in ('done', 'cancel'))
workcenter.workorder_pending_count = result[workcenter.id].get('pending', 0)
workcenter.workcenter_load = result_duration_expected[workcenter.id]
workcenter.workorder_ready_count = result[workcenter.id].get('ready', 0)
workcenter.workorder_progress_count = result[workcenter.id].get('progress', 0)
workcenter.workorder_late_count = count_data.get(workcenter.id, 0)
@api.depends('time_ids', 'time_ids.date_end', 'time_ids.loss_type')
def _compute_working_state(self):
for workcenter in self:
# We search for a productivity line associated to this workcenter having no `date_end`.
# If we do not find one, the workcenter is not currently being used. If we find one, according
# to its `type_loss`, the workcenter is either being used or blocked.
time_log = self.env['mrp.workcenter.productivity'].search([
('workcenter_id', '=', workcenter.id),
('date_end', '=', False)
], limit=1)
if not time_log:
# the workcenter is not being used
workcenter.working_state = 'normal'
elif time_log.loss_type in ('productive', 'performance'):
# the productivity line has a `loss_type` that means the workcenter is being used
workcenter.working_state = 'done'
else:
# the workcenter is blocked
workcenter.working_state = 'blocked'
def _compute_blocked_time(self):
# TDE FIXME: productivity loss type should be only losses, probably count other time logs differently ??
data = self.env['mrp.workcenter.productivity']._read_group([
('date_start', '>=', fields.Datetime.to_string(datetime.now() - relativedelta.relativedelta(months=1))),
('workcenter_id', 'in', self.ids),
('date_end', '!=', False),
('loss_type', '!=', 'productive')],
['duration', 'workcenter_id'], ['workcenter_id'], lazy=False)
count_data = dict((item['workcenter_id'][0], item['duration']) for item in data)
for workcenter in self:
workcenter.blocked_time = count_data.get(workcenter.id, 0.0) / 60.0
def _compute_productive_time(self):
# TDE FIXME: productivity loss type should be only losses, probably count other time logs differently
data = self.env['mrp.workcenter.productivity']._read_group([
('date_start', '>=', fields.Datetime.to_string(datetime.now() - relativedelta.relativedelta(months=1))),
('workcenter_id', 'in', self.ids),
('date_end', '!=', False),
('loss_type', '=', 'productive')],
['duration', 'workcenter_id'], ['workcenter_id'], lazy=False)
count_data = dict((item['workcenter_id'][0], item['duration']) for item in data)
for workcenter in self:
workcenter.productive_time = count_data.get(workcenter.id, 0.0) / 60.0
@api.depends('blocked_time', 'productive_time')
def _compute_oee(self):
for order in self:
if order.productive_time:
order.oee = round(order.productive_time * 100.0 / (order.productive_time + order.blocked_time), 2)
else:
order.oee = 0.0
def _compute_performance(self):
wo_data = self.env['mrp.workorder']._read_group([
('date_start', '>=', fields.Datetime.to_string(datetime.now() - relativedelta.relativedelta(months=1))),
('workcenter_id', 'in', self.ids),
('state', '=', 'done')], ['duration_expected', 'workcenter_id', 'duration'], ['workcenter_id'], lazy=False)
duration_expected = dict((data['workcenter_id'][0], data['duration_expected']) for data in wo_data)
duration = dict((data['workcenter_id'][0], data['duration']) for data in wo_data)
for workcenter in self:
if duration.get(workcenter.id):
workcenter.performance = 100 * duration_expected.get(workcenter.id, 0.0) / duration[workcenter.id]
else:
workcenter.performance = 0.0
@api.constrains('default_capacity')
def _check_capacity(self):
if any(workcenter.default_capacity <= 0.0 for workcenter in self):
raise exceptions.UserError(_('The capacity must be strictly positive.'))
def unblock(self):
self.ensure_one()
if self.working_state != 'blocked':
raise exceptions.UserError(_("It has already been unblocked."))
times = self.env['mrp.workcenter.productivity'].search([('workcenter_id', '=', self.id), ('date_end', '=', False)])
times.write({'date_end': datetime.now()})
return {'type': 'ir.actions.client', 'tag': 'reload'}
@api.model_create_multi
def create(self, vals_list):
# resource_type is 'human' by default. As we are not living in
# /r/latestagecapitalism, workcenters are 'material'
records = super(MrpWorkcenter, self.with_context(default_resource_type='material')).create(vals_list)
return records
def write(self, vals):
if 'company_id' in vals:
self.resource_id.company_id = vals['company_id']
return super(MrpWorkcenter, self).write(vals)
def action_show_operations(self):
self.ensure_one()
action = self.env['ir.actions.actions']._for_xml_id('mrp.mrp_routing_action')
action['domain'] = [('workcenter_id', '=', self.id)]
action['context'] = {
'default_workcenter_id': self.id,
}
return action
def action_work_order(self):
action = self.env["ir.actions.actions"]._for_xml_id("mrp.action_work_orders")
return action
def _get_unavailability_intervals(self, start_datetime, end_datetime):
"""Get the unavailabilities intervals for the workcenters in `self`.
Return the list of unavailabilities (a tuple of datetimes) indexed
by workcenter id.
:param start_datetime: filter unavailability with only slots after this start_datetime
:param end_datetime: filter unavailability with only slots before this end_datetime
:rtype: dict
"""
unavailability_ressources = self.resource_id._get_unavailable_intervals(start_datetime, end_datetime)
return {wc.id: unavailability_ressources.get(wc.resource_id.id, []) for wc in self}
def _get_first_available_slot(self, start_datetime, duration):
"""Get the first available interval for the workcenter in `self`.
The available interval is disjoinct with all other workorders planned on this workcenter, but
can overlap the time-off of the related calendar (inverse of the working hours).
Return the first available interval (start datetime, end datetime) or,
if there is none before 700 days, a tuple error (False, 'error message').
:param start_datetime: begin the search at this datetime
:param duration: minutes needed to make the workorder (float)
:rtype: tuple
"""
self.ensure_one()
start_datetime, revert = make_aware(start_datetime)
resource = self.resource_id
get_available_intervals = partial(self.resource_calendar_id._work_intervals_batch, domain=[('time_type', 'in', ['other', 'leave'])], resources=resource, tz=timezone(self.resource_calendar_id.tz))
get_workorder_intervals = partial(self.resource_calendar_id._leave_intervals_batch, domain=[('time_type', '=', 'other')], resources=resource, tz=timezone(self.resource_calendar_id.tz))
remaining = duration
start_interval = start_datetime
delta = timedelta(days=14)
for n in range(50): # 50 * 14 = 700 days in advance (hardcoded)
dt = start_datetime + delta * n
available_intervals = get_available_intervals(dt, dt + delta)[resource.id]
workorder_intervals = get_workorder_intervals(dt, dt + delta)[resource.id]
for start, stop, dummy in available_intervals:
# Shouldn't loop more than 2 times because the available_intervals contains the workorder_intervals
# And remaining == duration can only occur at the first loop and at the interval intersection (cannot happen several time because available_intervals > workorder_intervals
for _i in range(2):
interval_minutes = (stop - start).total_seconds() / 60
# If the remaining minutes has never decrease update start_interval
if remaining == duration:
start_interval = start
# If there is a overlap between the possible available interval and a others WO
if Intervals([(start_interval, start + timedelta(minutes=min(remaining, interval_minutes)), dummy)]) & workorder_intervals:
remaining = duration
elif float_compare(interval_minutes, remaining, precision_digits=3) >= 0:
return revert(start_interval), revert(start + timedelta(minutes=remaining))
else:
# Decrease a part of the remaining duration
remaining -= interval_minutes
# Go to the next available interval because the possible current interval duration has been used
break
return False, 'Not available slot 700 days after the planned start'
def action_archive(self):
res = super().action_archive()
filtered_workcenters = ", ".join(workcenter.name for workcenter in self.filtered('routing_line_ids'))
if filtered_workcenters:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _("Note that archived work center(s): '%s' is/are still linked to active Bill of Materials, which means that operations can still be planned on it/them. "
"To prevent this, deletion of the work center is recommended instead.", filtered_workcenters),
'type': 'warning',
'sticky': True, #True/False will display for few seconds if false
'next': {'type': 'ir.actions.act_window_close'},
},
}
return res
def _get_capacity(self, product):
product_capacity = self.capacity_ids.filtered(lambda capacity: capacity.product_id == product)
return product_capacity.capacity if product_capacity else self.default_capacity
def _get_expected_duration(self, product_id):
"""Compute the expected duration when using this work-center
Always include workcenter startup time and clean-up time.
In case there are specific capacities defined in the workcenter
that matches the product we are producing. Add the extra-time.
"""
capacity = self.capacity_ids.filtered(lambda p: p.product_id == product_id)
return self.time_start + self.time_stop + (capacity.time_start + capacity.time_stop if capacity else 0.0)
class WorkcenterTag(models.Model):
_name = 'mrp.workcenter.tag'
_description = 'Add tag for the workcenter'
_order = 'name'
def _get_default_color(self):
return randint(1, 11)
name = fields.Char("Tag Name", required=True)
color = fields.Integer("Color Index", default=_get_default_color)
_sql_constraints = [
('tag_name_unique', 'unique(name)',
'The tag name must be unique.'),
]
class MrpWorkcenterProductivityLossType(models.Model):
_name = "mrp.workcenter.productivity.loss.type"
_description = 'MRP Workorder productivity losses'
_rec_name = 'loss_type'
@api.depends('loss_type')
def name_get(self):
""" As 'category' field in form view is a Many2one, its value will be in
lower case. In order to display its value capitalized 'name_get' is
overrided.
"""
result = []
for rec in self:
result.append((rec.id, rec.loss_type.title()))
return result
loss_type = fields.Selection([
('availability', 'Availability'),
('performance', 'Performance'),
('quality', 'Quality'),
('productive', 'Productive')], string='Category', default='availability', required=True)
class MrpWorkcenterProductivityLoss(models.Model):
_name = "mrp.workcenter.productivity.loss"
_description = "Workcenter Productivity Losses"
_order = "sequence, id"
name = fields.Char('Blocking Reason', required=True)
sequence = fields.Integer('Sequence', default=1)
manual = fields.Boolean('Is a Blocking Reason', default=True)
loss_id = fields.Many2one('mrp.workcenter.productivity.loss.type', domain=([('loss_type', 'in', ['quality', 'availability'])]), string='Category')
loss_type = fields.Selection(string='Effectiveness Category', related='loss_id.loss_type', store=True, readonly=False)
def _convert_to_duration(self, date_start, date_stop, workcenter=False):
""" Convert a date range into a duration in minutes.
If the productivity type is not from an employee (extra hours are allow)
and the workcenter has a calendar, convert the dates into a duration based on
working hours.
"""
duration = 0
for productivity_loss in self:
if (productivity_loss.loss_type not in ('productive', 'performance')) and workcenter and workcenter.resource_calendar_id:
r = workcenter._get_work_days_data_batch(date_start, date_stop)[workcenter.id]['hours']
duration = max(duration, r * 60)
else:
duration = max(duration, (date_stop - date_start).total_seconds() / 60.0)
return round(duration, 2)
class MrpWorkcenterProductivity(models.Model):
_name = "mrp.workcenter.productivity"
_description = "Workcenter Productivity Log"
_order = "id desc"
_rec_name = "loss_id"
_check_company_auto = True
def _get_default_company_id(self):
company_id = False
if self.env.context.get('default_company_id'):
company_id = self.env.context['default_company_id']
if not company_id and self.env.context.get('default_workorder_id'):
workorder = self.env['mrp.workorder'].browse(self.env.context['default_workorder_id'])
company_id = workorder.company_id
if not company_id and self.env.context.get('default_workcenter_id'):
workcenter = self.env['mrp.workcenter'].browse(self.env.context['default_workcenter_id'])
company_id = workcenter.company_id
if not company_id:
company_id = self.env.company
return company_id
production_id = fields.Many2one('mrp.production', string='Manufacturing Order', related='workorder_id.production_id', readonly=True)
workcenter_id = fields.Many2one('mrp.workcenter', "Work Center", required=True, check_company=True, index=True)
company_id = fields.Many2one(
'res.company', required=True, index=True,
default=lambda self: self._get_default_company_id())
workorder_id = fields.Many2one('mrp.workorder', 'Work Order', check_company=True, index=True)
user_id = fields.Many2one(
'res.users', "User",
default=lambda self: self.env.uid)
loss_id = fields.Many2one(
'mrp.workcenter.productivity.loss', "Loss Reason",
ondelete='restrict', required=True)
loss_type = fields.Selection(
string="Effectiveness", related='loss_id.loss_type', store=True, readonly=False)
description = fields.Text('Description')
date_start = fields.Datetime('Start Date', default=fields.Datetime.now, required=True)
date_end = fields.Datetime('End Date')
duration = fields.Float('Duration', compute='_compute_duration', store=True)
@api.depends('date_end', 'date_start')
def _compute_duration(self):
for blocktime in self:
if blocktime.date_start and blocktime.date_end:
blocktime.duration = blocktime.loss_id._convert_to_duration(blocktime.date_start.replace(microsecond=0), blocktime.date_end.replace(microsecond=0), blocktime.workcenter_id)
else:
blocktime.duration = 0.0
@api.constrains('workorder_id')
def _check_open_time_ids(self):
for workorder in self.workorder_id:
open_time_ids_by_user = self.env["mrp.workcenter.productivity"].read_group([("id", "in", workorder.time_ids.ids), ("date_end", "=", False)], ["user_id", "open_time_ids_count:count(id)"], ["user_id"])
if any(data["open_time_ids_count"] > 1 for data in open_time_ids_by_user):
raise ValidationError(_('The Workorder (%s) cannot be started twice!', workorder.display_name))
def button_block(self):
self.ensure_one()
self.workcenter_id.order_ids.end_all()
def _close(self):
underperformance_timers = self.env['mrp.workcenter.productivity']
for timer in self:
wo = timer.workorder_id
timer.write({'date_end': fields.Datetime.now()})
if wo.duration > wo.duration_expected:
productive_date_end = timer.date_end - relativedelta.relativedelta(minutes=wo.duration - wo.duration_expected)
if productive_date_end <= timer.date_start:
underperformance_timers |= timer
else:
underperformance_timers |= timer.copy({'date_start': productive_date_end})
timer.write({'date_end': productive_date_end})
if underperformance_timers:
underperformance_type = self.env['mrp.workcenter.productivity.loss'].search([('loss_type', '=', 'performance')], limit=1)
if not underperformance_type:
raise UserError(_("You need to define at least one unactive productivity loss in the category 'Performance'. Create one from the Manufacturing app, menu: Configuration / Productivity Losses."))
underperformance_timers.write({'loss_id': underperformance_type.id})
class MrpWorkCenterCapacity(models.Model):
_name = 'mrp.workcenter.capacity'
_description = 'Work Center Capacity'
_check_company_auto = True
workcenter_id = fields.Many2one('mrp.workcenter', string='Work Center', required=True)
product_id = fields.Many2one('product.product', string='Product', required=True)
product_uom_id = fields.Many2one('uom.uom', string='Product UoM', related='product_id.uom_id')
capacity = fields.Float('Capacity', default=1.0, help="Number of pieces that can be produced in parallel for this product.")
time_start = fields.Float('Setup Time (minutes)', help="Additional time in minutes for the setup.")
time_stop = fields.Float('Cleanup Time (minutes)', help="Additional time in minutes for the cleaning.")
_sql_constraints = [
('positive_capacity', 'CHECK(capacity > 0)', 'Capacity should be a positive number.'),
('unique_product', 'UNIQUE(workcenter_id, product_id)', 'Product capacity should be unique for each workcenter.'),
]

View file

@ -0,0 +1,911 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
from collections import defaultdict
import json
from odoo import api, fields, models, _, SUPERUSER_ID
from odoo.exceptions import UserError, ValidationError
from odoo.osv import expression
from odoo.tools import float_compare, float_round, format_datetime
class MrpWorkorder(models.Model):
_name = 'mrp.workorder'
_description = 'Work Order'
def _read_group_workcenter_id(self, workcenters, domain, order):
workcenter_ids = self.env.context.get('default_workcenter_id')
if not workcenter_ids:
workcenter_ids = workcenters._search([], order=order, access_rights_uid=SUPERUSER_ID)
return workcenters.browse(workcenter_ids)
name = fields.Char(
'Work Order', required=True,
states={'done': [('readonly', True)], 'cancel': [('readonly', True)]})
workcenter_id = fields.Many2one(
'mrp.workcenter', 'Work Center', required=True,
states={'done': [('readonly', True)], 'cancel': [('readonly', True)], 'progress': [('readonly', True)]},
group_expand='_read_group_workcenter_id', check_company=True)
working_state = fields.Selection(
string='Workcenter Status', related='workcenter_id.working_state') # technical: used in views only
product_id = fields.Many2one(related='production_id.product_id', readonly=True, store=True, check_company=True)
product_tracking = fields.Selection(related="product_id.tracking")
product_uom_id = fields.Many2one('uom.uom', 'Unit of Measure', required=True, readonly=True)
production_id = fields.Many2one('mrp.production', 'Manufacturing Order', required=True, check_company=True, readonly=True)
production_availability = fields.Selection(
string='Stock Availability', readonly=True,
related='production_id.reservation_state', store=True) # Technical: used in views and domains only
production_state = fields.Selection(
string='Production State', readonly=True,
related='production_id.state') # Technical: used in views only
production_bom_id = fields.Many2one('mrp.bom', related='production_id.bom_id')
qty_production = fields.Float('Original Production Quantity', readonly=True, related='production_id.product_qty')
company_id = fields.Many2one(related='production_id.company_id')
qty_producing = fields.Float(
compute='_compute_qty_producing', inverse='_set_qty_producing',
string='Currently Produced Quantity', digits='Product Unit of Measure')
qty_remaining = fields.Float('Quantity To Be Produced', compute='_compute_qty_remaining', digits='Product Unit of Measure')
qty_produced = fields.Float(
'Quantity', default=0.0,
readonly=True,
digits='Product Unit of Measure',
copy=False,
help="The number of products already handled by this work order")
is_produced = fields.Boolean(string="Has Been Produced",
compute='_compute_is_produced')
state = fields.Selection([
('pending', 'Waiting for another WO'),
('waiting', 'Waiting for components'),
('ready', 'Ready'),
('progress', 'In Progress'),
('done', 'Finished'),
('cancel', 'Cancelled')], string='Status',
compute='_compute_state', store=True,
default='pending', copy=False, readonly=True, recursive=True, index=True)
leave_id = fields.Many2one(
'resource.calendar.leaves',
help='Slot into workcenter calendar once planned',
check_company=True, copy=False)
date_planned_start = fields.Datetime(
'Scheduled Start Date',
compute='_compute_dates_planned',
inverse='_set_dates_planned',
states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
store=True, copy=False)
date_planned_finished = fields.Datetime(
'Scheduled End Date',
compute='_compute_dates_planned',
inverse='_set_dates_planned',
states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
store=True, copy=False)
date_start = fields.Datetime(
'Start Date', copy=False,
states={'done': [('readonly', True)], 'cancel': [('readonly', True)]})
date_finished = fields.Datetime(
'End Date', copy=False,
states={'done': [('readonly', True)], 'cancel': [('readonly', True)]})
duration_expected = fields.Float(
'Expected Duration', digits=(16, 2), compute='_compute_duration_expected',
states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
readonly=False, store=True) # in minutes
duration = fields.Float(
'Real Duration', compute='_compute_duration', inverse='_set_duration',
readonly=False, store=True, copy=False)
duration_unit = fields.Float(
'Duration Per Unit', compute='_compute_duration',
group_operator="avg", readonly=True, store=True)
duration_percent = fields.Integer(
'Duration Deviation (%)', compute='_compute_duration',
group_operator="avg", readonly=True, store=True)
progress = fields.Float('Progress Done (%)', digits=(16, 2), compute='_compute_progress')
operation_id = fields.Many2one(
'mrp.routing.workcenter', 'Operation', check_company=True)
# Should be used differently as BoM can change in the meantime
worksheet = fields.Binary(
'Worksheet', related='operation_id.worksheet', readonly=True)
worksheet_type = fields.Selection(
string='Worksheet Type', related='operation_id.worksheet_type', readonly=True)
worksheet_google_slide = fields.Char(
'Worksheet URL', related='operation_id.worksheet_google_slide', readonly=True)
operation_note = fields.Html("Description", related='operation_id.note', readonly=True)
move_raw_ids = fields.One2many(
'stock.move', 'workorder_id', 'Raw Moves',
domain=[('raw_material_production_id', '!=', False), ('production_id', '=', False)])
move_finished_ids = fields.One2many(
'stock.move', 'workorder_id', 'Finished Moves',
domain=[('raw_material_production_id', '=', False), ('production_id', '!=', False)])
move_line_ids = fields.One2many(
'stock.move.line', 'workorder_id', 'Moves to Track',
help="Inventory moves for which you must scan a lot number at this work order")
finished_lot_id = fields.Many2one(
'stock.lot', string='Lot/Serial Number', compute='_compute_finished_lot_id',
inverse='_set_finished_lot_id', domain="[('product_id', '=', product_id), ('company_id', '=', company_id)]",
check_company=True, search='_search_finished_lot_id')
time_ids = fields.One2many(
'mrp.workcenter.productivity', 'workorder_id', copy=False)
is_user_working = fields.Boolean(
'Is the Current User Working', compute='_compute_working_users') # technical: is the current user working
working_user_ids = fields.One2many('res.users', string='Working user on this work order.', compute='_compute_working_users')
last_working_user_id = fields.One2many('res.users', string='Last user that worked on this work order.', compute='_compute_working_users')
costs_hour = fields.Float(
string='Cost per hour',
default=0.0, group_operator="avg")
# Technical field to store the hourly cost of workcenter at time of work order completion (i.e. to keep a consistent cost).',
scrap_ids = fields.One2many('stock.scrap', 'workorder_id')
scrap_count = fields.Integer(compute='_compute_scrap_move_count', string='Scrap Move')
production_date = fields.Datetime('Production Date', related='production_id.date_planned_start', store=True)
json_popover = fields.Char('Popover Data JSON', compute='_compute_json_popover')
show_json_popover = fields.Boolean('Show Popover?', compute='_compute_json_popover')
consumption = fields.Selection(related='production_id.consumption')
qty_reported_from_previous_wo = fields.Float('Carried Quantity', digits='Product Unit of Measure', copy=False,
help="The quantity already produced awaiting allocation in the backorders chain.")
is_planned = fields.Boolean(related='production_id.is_planned')
allow_workorder_dependencies = fields.Boolean(related='production_id.allow_workorder_dependencies')
blocked_by_workorder_ids = fields.Many2many('mrp.workorder', relation="mrp_workorder_dependencies_rel",
column1="workorder_id", column2="blocked_by_id", string="Blocked By",
domain="[('allow_workorder_dependencies', '=', True), ('id', '!=', id), ('production_id', '=', production_id)]",
copy=False)
needed_by_workorder_ids = fields.Many2many('mrp.workorder', relation="mrp_workorder_dependencies_rel",
column1="blocked_by_id", column2="workorder_id", string="Blocks",
domain="[('allow_workorder_dependencies', '=', True), ('id', '!=', id), ('production_id', '=', production_id)]",
copy=False)
@api.depends('production_availability', 'blocked_by_workorder_ids.state')
def _compute_state(self):
# Force to compute the production_availability right away.
# It is a trick to force that the state of workorder is computed at the end of the
# cyclic depends with the mo.state, mo.reservation_state and wo.state and avoid recursion error
self.mapped('production_availability')
for workorder in self:
if workorder.state == 'pending':
if all([wo.state in ('done', 'cancel') for wo in workorder.blocked_by_workorder_ids]):
workorder.state = 'ready' if workorder.production_availability == 'assigned' else 'waiting'
continue
if workorder.state not in ('waiting', 'ready'):
continue
if not all([wo.state in ('done', 'cancel') for wo in workorder.blocked_by_workorder_ids]):
workorder.state = 'pending'
continue
if workorder.production_availability not in ('waiting', 'confirmed', 'assigned'):
continue
if workorder.production_availability == 'assigned' and workorder.state == 'waiting':
workorder.state = 'ready'
elif workorder.production_availability != 'assigned' and workorder.state == 'ready':
workorder.state = 'waiting'
@api.depends('production_state', 'date_planned_start', 'date_planned_finished')
def _compute_json_popover(self):
if self.ids:
conflicted_dict = self._get_conflicted_workorder_ids()
for wo in self:
infos = []
if not wo.date_planned_start or not wo.date_planned_finished or not wo.ids:
wo.show_json_popover = False
wo.json_popover = False
continue
if wo.state in ('pending', 'waiting', 'ready'):
previous_wos = wo.blocked_by_workorder_ids
prev_start = min([workorder.date_planned_start for workorder in previous_wos]) if previous_wos else False
prev_finished = max([workorder.date_planned_finished for workorder in previous_wos]) if previous_wos else False
if wo.state == 'pending' and prev_start and not (prev_start > wo.date_planned_start):
infos.append({
'color': 'text-primary',
'msg': _("Waiting the previous work order, planned from %(start)s to %(end)s",
start=format_datetime(self.env, prev_start, dt_format=False),
end=format_datetime(self.env, prev_finished, dt_format=False))
})
if wo.date_planned_finished < fields.Datetime.now():
infos.append({
'color': 'text-warning',
'msg': _("The work order should have already been processed.")
})
if prev_start and prev_start > wo.date_planned_start:
infos.append({
'color': 'text-danger',
'msg': _("Scheduled before the previous work order, planned from %(start)s to %(end)s",
start=format_datetime(self.env, prev_start, dt_format=False),
end=format_datetime(self.env, prev_finished, dt_format=False))
})
if conflicted_dict.get(wo.id):
infos.append({
'color': 'text-danger',
'msg': _("Planned at the same time as other workorder(s) at %s", wo.workcenter_id.display_name)
})
color_icon = infos and infos[-1]['color'] or False
wo.show_json_popover = bool(color_icon)
wo.json_popover = json.dumps({
'popoverTemplate': 'mrp.workorderPopover',
'infos': infos,
'color': color_icon,
'icon': 'fa-exclamation-triangle' if color_icon in ['text-warning', 'text-danger'] else 'fa-info-circle',
'replan': color_icon not in [False, 'text-primary']
})
@api.depends('production_id.lot_producing_id')
def _compute_finished_lot_id(self):
for workorder in self:
workorder.finished_lot_id = workorder.production_id.lot_producing_id
def _search_finished_lot_id(self, operator, value):
return [('production_id.lot_producing_id', operator, value)]
def _set_finished_lot_id(self):
for workorder in self:
workorder.production_id.lot_producing_id = workorder.finished_lot_id
@api.depends('production_id.qty_producing')
def _compute_qty_producing(self):
for workorder in self:
workorder.qty_producing = workorder.production_id.qty_producing
def _set_qty_producing(self):
for workorder in self:
if workorder.qty_producing != 0 and workorder.production_id.qty_producing != workorder.qty_producing:
workorder.production_id.qty_producing = workorder.qty_producing
workorder.production_id._set_qty_producing()
# Both `date_planned_start` and `date_planned_finished` are related fields on `leave_id`. Let's say
# we slide a workorder on a gantt view, a single call to write is made with both
# fields Changes. As the ORM doesn't batch the write on related fields and instead
# makes multiple call, the constraint check_dates() is raised.
# That's why the compute and set methods are needed. to ensure the dates are updated
# in the same time.
@api.depends('leave_id')
def _compute_dates_planned(self):
for workorder in self:
workorder.date_planned_start = workorder.leave_id.date_from
workorder.date_planned_finished = workorder.leave_id.date_to
def _set_dates_planned(self):
if not self[0].date_planned_start:
if not self.leave_id:
return
raise UserError(_("It is not possible to unplan one single Work Order. "
"You should unplan the Manufacturing Order instead in order to unplan all the linked operations."))
date_from = self[0].date_planned_start
for wo in self:
if not wo.date_planned_finished:
wo.date_planned_finished = wo._calculate_date_planned_finished()
date_to = self[0].date_planned_finished
to_write = self.env['mrp.workorder']
for wo in self.sudo():
if wo.leave_id:
to_write |= wo
else:
wo.leave_id = wo.env['resource.calendar.leaves'].create({
'name': wo.display_name,
'calendar_id': wo.workcenter_id.resource_calendar_id.id,
'date_from': date_from,
'date_to': date_to,
'resource_id': wo.workcenter_id.resource_id.id,
'time_type': 'other',
})
to_write.leave_id.write({
'date_from': date_from,
'date_to': date_to,
})
@api.constrains('blocked_by_workorder_ids')
def _check_no_cyclic_dependencies(self):
if not self._check_m2m_recursion('blocked_by_workorder_ids'):
raise ValidationError(_("You cannot create cyclic dependency."))
def name_get(self):
res = []
for wo in self:
if len(wo.production_id.workorder_ids) == 1:
res.append((wo.id, "%s - %s - %s" % (wo.production_id.name, wo.product_id.name, wo.name)))
else:
res.append((wo.id, "%s - %s - %s - %s" % (wo.production_id.workorder_ids.ids.index(wo._origin.id) + 1, wo.production_id.name, wo.product_id.name, wo.name)))
return res
def unlink(self):
# Removes references to workorder to avoid Validation Error
(self.mapped('move_raw_ids') | self.mapped('move_finished_ids')).write({'workorder_id': False})
self.mapped('leave_id').unlink()
mo_dirty = self.production_id.filtered(lambda mo: mo.state in ("confirmed", "progress", "to_close"))
for workorder in self:
workorder.blocked_by_workorder_ids.needed_by_workorder_ids = workorder.needed_by_workorder_ids
res = super().unlink()
# We need to go through `_action_confirm` for all workorders of the current productions to
# make sure the links between them are correct (`next_work_order_id` could be obsolete now).
mo_dirty.workorder_ids._action_confirm()
return res
@api.depends('production_id.product_qty', 'qty_produced', 'production_id.product_uom_id')
def _compute_is_produced(self):
self.is_produced = False
for order in self.filtered(lambda p: p.production_id and p.production_id.product_uom_id):
rounding = order.production_id.product_uom_id.rounding
order.is_produced = float_compare(order.qty_produced, order.production_id.product_qty, precision_rounding=rounding) >= 0
@api.depends('operation_id', 'workcenter_id', 'qty_producing', 'qty_production')
def _compute_duration_expected(self):
for workorder in self:
# Recompute the duration expected if the qty_producing has been changed:
# compare with the origin record if it happens during an onchange
if workorder.state not in ['done', 'cancel'] and (workorder.qty_producing != workorder.qty_production
or (workorder._origin != workorder and workorder._origin.qty_producing and workorder.qty_producing != workorder._origin.qty_producing)):
workorder.duration_expected = workorder._get_duration_expected()
@api.depends('time_ids.duration', 'qty_produced')
def _compute_duration(self):
for order in self:
order.duration = sum(order.time_ids.mapped('duration'))
order.duration_unit = round(order.duration / max(order.qty_produced, 1), 2) # rounding 2 because it is a time
if order.duration_expected:
order.duration_percent = max(-2147483648, min(2147483647, 100 * (order.duration_expected - order.duration) / order.duration_expected))
else:
order.duration_percent = 0
def _set_duration(self):
def _float_duration_to_second(duration):
minutes = duration // 1
seconds = (duration % 1) * 60
return minutes * 60 + seconds
for order in self:
old_order_duration = sum(order.time_ids.mapped('duration'))
new_order_duration = order.duration
if new_order_duration == old_order_duration:
continue
delta_duration = new_order_duration - old_order_duration
if delta_duration > 0:
enddate = datetime.now()
date_start = enddate - timedelta(seconds=_float_duration_to_second(delta_duration))
if order.duration_expected >= new_order_duration or old_order_duration >= order.duration_expected:
# either only productive or only performance (i.e. reduced speed) time respectively
self.env['mrp.workcenter.productivity'].create(
order._prepare_timeline_vals(new_order_duration, date_start, enddate)
)
else:
# split between productive and performance (i.e. reduced speed) times
maxdate = fields.Datetime.from_string(enddate) - relativedelta(minutes=new_order_duration - order.duration_expected)
self.env['mrp.workcenter.productivity'].create([
order._prepare_timeline_vals(order.duration_expected, date_start, maxdate),
order._prepare_timeline_vals(new_order_duration, maxdate, enddate)
])
else:
duration_to_remove = abs(delta_duration)
timelines_to_unlink = self.env['mrp.workcenter.productivity']
for timeline in order.time_ids.sorted():
if duration_to_remove <= 0.0:
break
if timeline.duration <= duration_to_remove:
duration_to_remove -= timeline.duration
timelines_to_unlink |= timeline
else:
new_time_line_duration = timeline.duration - duration_to_remove
timeline.date_start = timeline.date_end - timedelta(seconds=_float_duration_to_second(new_time_line_duration))
break
timelines_to_unlink.unlink()
@api.depends('duration', 'duration_expected', 'state')
def _compute_progress(self):
for order in self:
if order.state == 'done':
order.progress = 100
elif order.duration_expected:
order.progress = order.duration * 100 / order.duration_expected
else:
order.progress = 0
def _compute_working_users(self):
""" Checks whether the current user is working, all the users currently working and the last user that worked. """
for order in self:
order.working_user_ids = [(4, order.id) for order in order.time_ids.filtered(lambda time: not time.date_end).sorted('date_start').mapped('user_id')]
if order.working_user_ids:
order.last_working_user_id = order.working_user_ids[-1]
elif order.time_ids:
order.last_working_user_id = order.time_ids.filtered('date_end').sorted('date_end')[-1].user_id if order.time_ids.filtered('date_end') else order.time_ids[-1].user_id
else:
order.last_working_user_id = False
if order.time_ids.filtered(lambda x: (x.user_id.id == self.env.user.id) and (not x.date_end) and (x.loss_type in ('productive', 'performance'))):
order.is_user_working = True
else:
order.is_user_working = False
def _compute_scrap_move_count(self):
data = self.env['stock.scrap']._read_group([('workorder_id', 'in', self.ids)], ['workorder_id'], ['workorder_id'])
count_data = dict((item['workorder_id'][0], item['workorder_id_count']) for item in data)
for workorder in self:
workorder.scrap_count = count_data.get(workorder.id, 0)
@api.onchange('operation_id')
def _onchange_operation_id(self):
if self.operation_id:
self.name = self.operation_id.name
self.workcenter_id = self.operation_id.workcenter_id.id
@api.onchange('date_planned_start', 'duration_expected', 'workcenter_id')
def _onchange_date_planned_start(self):
if self.date_planned_start and self.workcenter_id:
self.date_planned_finished = self._calculate_date_planned_finished()
def _calculate_date_planned_finished(self, date_planned_start=False):
return self.workcenter_id.resource_calendar_id.plan_hours(
self.duration_expected / 60.0, date_planned_start or self.date_planned_start,
compute_leaves=True, domain=[('time_type', 'in', ['leave', 'other'])]
)
@api.onchange('date_planned_finished')
def _onchange_date_planned_finished(self):
if self.date_planned_start and self.date_planned_finished and self.workcenter_id:
self.duration_expected = self._calculate_duration_expected()
if not self.date_planned_finished and self.date_planned_start:
raise UserError(_("It is not possible to unplan one single Work Order. "
"You should unplan the Manufacturing Order instead in order to unplan all the linked operations."))
def _calculate_duration_expected(self, date_planned_start=False, date_planned_finished=False):
interval = self.workcenter_id.resource_calendar_id.get_work_duration_data(
date_planned_start or self.date_planned_start, date_planned_finished or self.date_planned_finished,
domain=[('time_type', 'in', ['leave', 'other'])]
)
return interval['hours'] * 60
@api.onchange('finished_lot_id')
def _onchange_finished_lot_id(self):
if self.production_id:
res = self.production_id._can_produce_serial_number(sn=self.finished_lot_id)
if res is not True:
return res
def write(self, values):
if 'production_id' in values and any(values['production_id'] != w.production_id.id for w in self):
raise UserError(_('You cannot link this work order to another manufacturing order.'))
if 'workcenter_id' in values:
for workorder in self:
if workorder.workcenter_id.id != values['workcenter_id']:
if workorder.state in ('progress', 'done', 'cancel'):
raise UserError(_('You cannot change the workcenter of a work order that is in progress or done.'))
workorder.leave_id.resource_id = self.env['mrp.workcenter'].browse(values['workcenter_id']).resource_id
if 'date_planned_start' in values or 'date_planned_finished' in values:
for workorder in self:
start_date = fields.Datetime.to_datetime(values.get('date_planned_start', workorder.date_planned_start))
end_date = fields.Datetime.to_datetime(values.get('date_planned_finished', workorder.date_planned_finished))
if start_date and end_date and start_date > end_date:
raise UserError(_('The planned end date of the work order cannot be prior to the planned start date, please correct this to save the work order.'))
if 'duration_expected' not in values and not self.env.context.get('bypass_duration_calculation'):
if values.get('date_planned_start') and values.get('date_planned_finished'):
computed_finished_time = workorder._calculate_date_planned_finished(start_date)
values['date_planned_finished'] = computed_finished_time
elif start_date and end_date:
computed_duration = workorder._calculate_duration_expected(date_planned_start=start_date, date_planned_finished=end_date)
values['duration_expected'] = computed_duration
# Update MO dates if the start date of the first WO or the
# finished date of the last WO is update.
if workorder == workorder.production_id.workorder_ids[0] and 'date_planned_start' in values:
if values['date_planned_start']:
workorder.production_id.with_context(force_date=True).write({
'date_planned_start': fields.Datetime.to_datetime(values['date_planned_start'])
})
if workorder == workorder.production_id.workorder_ids[-1] and 'date_planned_finished' in values:
if values['date_planned_finished']:
workorder.production_id.with_context(force_date=True).write({
'date_planned_finished': fields.Datetime.to_datetime(values['date_planned_finished'])
})
return super(MrpWorkorder, self).write(values)
@api.model_create_multi
def create(self, values):
res = super().create(values)
# Auto-confirm manually added workorders.
# We need to go through `_action_confirm` for all workorders of the current productions to
# make sure the links between them are correct.
if self.env.context.get('skip_confirm'):
return res
to_confirm = res.filtered(lambda wo: wo.production_id.state in ("confirmed", "progress", "to_close"))
to_confirm = to_confirm.production_id.workorder_ids
to_confirm._action_confirm()
return res
def _action_confirm(self):
for production in self.mapped("production_id"):
production._link_workorders_and_moves()
def _get_byproduct_move_to_update(self):
return self.production_id.move_finished_ids.filtered(lambda x: (x.product_id.id != self.production_id.product_id.id) and (x.state not in ('done', 'cancel')))
def _plan_workorder(self, replan=False):
self.ensure_one()
# Plan workorder after its predecessors
start_date = max(self.production_id.date_planned_start, datetime.now())
for workorder in self.blocked_by_workorder_ids:
workorder._plan_workorder(replan)
if workorder.date_planned_finished and workorder.date_planned_finished > start_date:
start_date = workorder.date_planned_finished
# Plan only suitable workorders
if self.state not in ['pending', 'waiting', 'ready']:
return
if self.leave_id:
if replan:
self.leave_id.unlink()
else:
return
# Consider workcenter and alternatives
workcenters = self.workcenter_id | self.workcenter_id.alternative_workcenter_ids
best_finished_date = datetime.max
vals = {}
for workcenter in workcenters:
# Compute theoretical duration
if self.workcenter_id == workcenter:
duration_expected = self.duration_expected
else:
duration_expected = self._get_duration_expected(alternative_workcenter=workcenter)
from_date, to_date = workcenter._get_first_available_slot(start_date, duration_expected)
# If the workcenter is unavailable, try planning on the next one
if not from_date:
continue
# Check if this workcenter is better than the previous ones
if to_date and to_date < best_finished_date:
best_start_date = from_date
best_finished_date = to_date
best_workcenter = workcenter
vals = {
'workcenter_id': workcenter.id,
'duration_expected': duration_expected,
}
# If none of the workcenter are available, raise
if best_finished_date == datetime.max:
raise UserError(_('Impossible to plan the workorder. Please check the workcenter availabilities.'))
# Create leave on chosen workcenter calendar
leave = self.env['resource.calendar.leaves'].create({
'name': self.display_name,
'calendar_id': best_workcenter.resource_calendar_id.id,
'date_from': best_start_date,
'date_to': best_finished_date,
'resource_id': best_workcenter.resource_id.id,
'time_type': 'other'
})
vals['leave_id'] = leave.id
self.write(vals)
def _cal_cost(self, times=None):
self.ensure_one()
times = times or self.time_ids
duration = sum(times.mapped('duration'))
return (duration / 60.0) * self.workcenter_id.costs_hour
@api.model
def gantt_unavailability(self, start_date, end_date, scale, group_bys=None, rows=None):
"""Get unavailabilities data to display in the Gantt view."""
workcenter_ids = set()
def traverse_inplace(func, row, **kargs):
res = func(row, **kargs)
if res:
kargs.update(res)
for row in row.get('rows'):
traverse_inplace(func, row, **kargs)
def search_workcenter_ids(row):
if row.get('groupedBy') and row.get('groupedBy')[0] == 'workcenter_id' and row.get('resId'):
workcenter_ids.add(row.get('resId'))
for row in rows:
traverse_inplace(search_workcenter_ids, row)
start_datetime = fields.Datetime.to_datetime(start_date)
end_datetime = fields.Datetime.to_datetime(end_date)
workcenters = self.env['mrp.workcenter'].browse(workcenter_ids)
unavailability_mapping = workcenters._get_unavailability_intervals(start_datetime, end_datetime)
# Only notable interval (more than one case) is send to the front-end (avoid sending useless information)
cell_dt = (scale in ['day', 'week'] and timedelta(hours=1)) or (scale == 'month' and timedelta(days=1)) or timedelta(days=28)
def add_unavailability(row, workcenter_id=None):
if row.get('groupedBy') and row.get('groupedBy')[0] == 'workcenter_id' and row.get('resId'):
workcenter_id = row.get('resId')
if workcenter_id:
notable_intervals = filter(lambda interval: interval[1] - interval[0] >= cell_dt, unavailability_mapping[workcenter_id])
row['unavailabilities'] = [{'start': interval[0], 'stop': interval[1]} for interval in notable_intervals]
return {'workcenter_id': workcenter_id}
for row in rows:
traverse_inplace(add_unavailability, row)
return rows
def button_start(self):
self.ensure_one()
if any(not time.date_end for time in self.time_ids.filtered(lambda t: t.user_id.id == self.env.user.id)):
return True
# As button_start is automatically called in the new view
if self.state in ('done', 'cancel'):
return True
if self.production_id.state != 'progress':
self.production_id.write({
'date_start': datetime.now(),
})
if self.product_tracking == 'serial' and self.qty_producing == 0:
self.qty_producing = 1.0
elif self.qty_producing == 0:
self.qty_producing = self.qty_remaining
if self._should_start_timer():
self.env['mrp.workcenter.productivity'].create(
self._prepare_timeline_vals(self.duration, datetime.now())
)
if self.state == 'progress':
return True
start_date = datetime.now()
vals = {
'state': 'progress',
'date_start': start_date,
}
if not self.leave_id:
leave = self.env['resource.calendar.leaves'].create({
'name': self.display_name,
'calendar_id': self.workcenter_id.resource_calendar_id.id,
'date_from': start_date,
'date_to': start_date + relativedelta(minutes=self.duration_expected),
'resource_id': self.workcenter_id.resource_id.id,
'time_type': 'other'
})
vals['leave_id'] = leave.id
return self.write(vals)
else:
if not self.date_planned_start or self.date_planned_start > start_date:
vals['date_planned_start'] = start_date
vals['date_planned_finished'] = self._calculate_date_planned_finished(start_date)
if self.date_planned_finished and self.date_planned_finished < start_date:
vals['date_planned_finished'] = start_date
return self.with_context(bypass_duration_calculation=True).write(vals)
def button_finish(self):
end_date = fields.Datetime.now()
for workorder in self:
if workorder.state in ('done', 'cancel'):
continue
workorder.end_all()
vals = {
'qty_produced': workorder.qty_produced or workorder.qty_producing or workorder.qty_production,
'state': 'done',
'date_finished': end_date,
'date_planned_finished': end_date,
'costs_hour': workorder.workcenter_id.costs_hour
}
if not workorder.date_start:
vals['date_start'] = end_date
if not workorder.date_planned_start or end_date < workorder.date_planned_start:
vals['date_planned_start'] = end_date
workorder.with_context(bypass_duration_calculation=True).write(vals)
return True
def _domain_mrp_workcenter_productivity(self, doall):
domain = [('workorder_id', 'in', self.ids), ('date_end', '=', False)]
if not doall:
domain = expression.AND([domain, [('user_id', '=', self.env.user.id)]])
return domain
def end_previous(self, doall=False):
"""
@param: doall: This will close all open time lines on the open work orders when doall = True, otherwise
only the one of the current user
"""
# TDE CLEANME
self.env['mrp.workcenter.productivity'].search(
self._domain_mrp_workcenter_productivity(doall),
limit=None if doall else 1
)._close()
return True
def end_all(self):
return self.end_previous(doall=True)
def button_pending(self):
self.end_previous()
return True
def button_unblock(self):
for order in self:
order.workcenter_id.unblock()
return True
def action_cancel(self):
self.leave_id.unlink()
self.end_all()
return self.write({'state': 'cancel'})
def action_replan(self):
"""Replan a work order.
It actually replans every "ready" or "pending"
work orders of the linked manufacturing orders.
"""
for production in self.production_id:
production._plan_workorders(replan=True)
return True
def button_done(self):
if any(x.state in ('done', 'cancel') for x in self):
raise UserError(_('A Manufacturing Order is already done or cancelled.'))
self.end_all()
end_date = datetime.now()
return self.write({
'state': 'done',
'date_finished': end_date,
'date_planned_finished': end_date,
'costs_hour': self.workcenter_id.costs_hour
})
def button_scrap(self):
self.ensure_one()
return {
'name': _('Scrap'),
'view_mode': 'form',
'res_model': 'stock.scrap',
'views': [(self.env.ref('stock.stock_scrap_form_view2').id, 'form')],
'type': 'ir.actions.act_window',
'context': {'default_company_id': self.production_id.company_id.id,
'default_workorder_id': self.id,
'default_production_id': self.production_id.id,
'product_ids': (self.production_id.move_raw_ids.filtered(lambda x: x.state not in ('done', 'cancel')) | self.production_id.move_finished_ids.filtered(lambda x: x.state == 'done')).mapped('product_id').ids},
'target': 'new',
}
def action_see_move_scrap(self):
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id("stock.action_stock_scrap")
action['domain'] = [('workorder_id', '=', self.id)]
return action
def action_open_wizard(self):
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id("mrp.mrp_workorder_mrp_production_form")
action['res_id'] = self.id
return action
@api.depends('qty_production', 'qty_reported_from_previous_wo', 'qty_produced', 'production_id.product_uom_id')
def _compute_qty_remaining(self):
for wo in self:
if wo.production_id.product_uom_id:
wo.qty_remaining = max(float_round(wo.qty_production - wo.qty_reported_from_previous_wo - wo.qty_produced, precision_rounding=wo.production_id.product_uom_id.rounding), 0)
else:
wo.qty_remaining = 0
def _get_duration_expected(self, alternative_workcenter=False, ratio=1):
self.ensure_one()
if not self.workcenter_id:
return self.duration_expected
if not self.operation_id:
duration_expected_working = (self.duration_expected - self.workcenter_id.time_start - self.workcenter_id.time_stop) * self.workcenter_id.time_efficiency / 100.0
if duration_expected_working < 0:
duration_expected_working = 0
if self.qty_producing not in (0, self.qty_production, self._origin.qty_producing):
qty_ratio = self.qty_producing / (self._origin.qty_producing or self.qty_production)
else:
qty_ratio = 1
return self.workcenter_id._get_expected_duration(self.product_id) + duration_expected_working * qty_ratio * ratio * 100.0 / self.workcenter_id.time_efficiency
qty_production = self.production_id.product_uom_id._compute_quantity(self.qty_producing or self.qty_production, self.production_id.product_id.uom_id)
capacity = self.workcenter_id._get_capacity(self.product_id)
cycle_number = float_round(qty_production / capacity, precision_digits=0, rounding_method='UP')
if alternative_workcenter:
# TODO : find a better alternative : the settings of workcenter can change
duration_expected_working = (self.duration_expected - self.workcenter_id._get_expected_duration(self.product_id)) * self.workcenter_id.time_efficiency / (100.0 * cycle_number)
if duration_expected_working < 0:
duration_expected_working = 0
capacity = alternative_workcenter._get_capacity(self.product_id)
alternative_wc_cycle_nb = float_round(qty_production / capacity, precision_digits=0, rounding_method='UP')
return alternative_workcenter._get_expected_duration(self.product_id) + alternative_wc_cycle_nb * duration_expected_working * 100.0 / alternative_workcenter.time_efficiency
time_cycle = self.operation_id.time_cycle
return self.workcenter_id._get_expected_duration(self.product_id) + cycle_number * time_cycle * 100.0 / self.workcenter_id.time_efficiency
def _get_conflicted_workorder_ids(self):
"""Get conlicted workorder(s) with self.
Conflict means having two workorders in the same time in the same workcenter.
:return: defaultdict with key as workorder id of self and value as related conflicted workorder
"""
self.flush_model(['state', 'date_planned_start', 'date_planned_finished', 'workcenter_id'])
sql = """
SELECT wo1.id, wo2.id
FROM mrp_workorder wo1, mrp_workorder wo2
WHERE
wo1.id IN %s
AND wo1.state IN ('pending', 'waiting', 'ready')
AND wo2.state IN ('pending', 'waiting', 'ready')
AND wo1.id != wo2.id
AND wo1.workcenter_id = wo2.workcenter_id
AND (DATE_TRUNC('second', wo2.date_planned_start), DATE_TRUNC('second', wo2.date_planned_finished))
OVERLAPS (DATE_TRUNC('second', wo1.date_planned_start), DATE_TRUNC('second', wo1.date_planned_finished))
"""
self.env.cr.execute(sql, [tuple(self.ids)])
res = defaultdict(list)
for wo1, wo2 in self.env.cr.fetchall():
res[wo1].append(wo2)
return res
def _prepare_timeline_vals(self, duration, date_start, date_end=False):
# Need a loss in case of the real time exceeding the expected
if not self.duration_expected or duration <= self.duration_expected:
loss_id = self.env['mrp.workcenter.productivity.loss'].search([('loss_type', '=', 'productive')], limit=1)
if not len(loss_id):
raise UserError(_("You need to define at least one productivity loss in the category 'Productivity'. Create one from the Manufacturing app, menu: Configuration / Productivity Losses."))
else:
loss_id = self.env['mrp.workcenter.productivity.loss'].search([('loss_type', '=', 'performance')], limit=1)
if not len(loss_id):
raise UserError(_("You need to define at least one productivity loss in the category 'Performance'. Create one from the Manufacturing app, menu: Configuration / Productivity Losses."))
return {
'workorder_id': self.id,
'workcenter_id': self.workcenter_id.id,
'description': _('Time Tracking: %(user)s', user=self.env.user.name),
'loss_id': loss_id[0].id,
'date_start': date_start.replace(microsecond=0),
'date_end': date_end.replace(microsecond=0) if date_end else date_end,
'user_id': self.env.user.id, # FIXME sle: can be inconsistent with company_id
'company_id': self.company_id.id,
}
def _update_finished_move(self):
""" Update the finished move & move lines in order to set the finished
product lot on it as well as the produced quantity. This method get the
information either from the last workorder or from the Produce wizard."""
production_move = self.production_id.move_finished_ids.filtered(
lambda move: move.product_id == self.product_id and
move.state not in ('done', 'cancel')
)
if not production_move:
return
if production_move.product_id.tracking != 'none':
if not self.finished_lot_id:
raise UserError(_('You need to provide a lot for the finished product.'))
move_line = production_move.move_line_ids.filtered(
lambda line: line.lot_id.id == self.finished_lot_id.id
)
if move_line:
if self.product_id.tracking == 'serial':
raise UserError(_('You cannot produce the same serial number twice.'))
move_line.reserved_uom_qty += self.qty_producing
move_line.qty_done += self.qty_producing
else:
quantity = self.product_uom_id._compute_quantity(self.qty_producing, self.product_id.uom_id, rounding_method='HALF-UP')
putaway_location = production_move.location_dest_id._get_putaway_strategy(self.product_id, quantity)
move_line.create({
'move_id': production_move.id,
'product_id': production_move.product_id.id,
'lot_id': self.finished_lot_id.id,
'reserved_uom_qty': self.qty_producing,
'product_uom_id': self.product_uom_id.id,
'qty_done': self.qty_producing,
'location_id': production_move.location_id.id,
'location_dest_id': putaway_location.id,
})
else:
rounding = production_move.product_uom.rounding
production_move._set_quantity_done(
float_round(self.qty_producing, precision_rounding=rounding)
)
def _check_sn_uniqueness(self):
# todo master: remove
pass
def _should_start_timer(self):
return True
def _update_qty_producing(self, quantity):
self.ensure_one()
if self.qty_producing:
self.qty_producing = quantity
def get_working_duration(self):
"""Get the additional duration for 'open times' i.e. productivity lines with no date_end."""
self.ensure_one()
duration = 0
for time in self.time_ids.filtered(lambda time: not time.date_end):
duration += (datetime.now() - time.date_start).total_seconds() / 60
return duration

View file

@ -0,0 +1,420 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import collections
from datetime import timedelta
from itertools import groupby
import operator as py_operator
from odoo import api, fields, models, _
from odoo.tools import groupby
from odoo.tools.float_utils import float_round, float_is_zero
OPERATORS = {
'<': py_operator.lt,
'>': py_operator.gt,
'<=': py_operator.le,
'>=': py_operator.ge,
'=': py_operator.eq,
'!=': py_operator.ne
}
class ProductTemplate(models.Model):
_inherit = "product.template"
bom_line_ids = fields.One2many('mrp.bom.line', 'product_tmpl_id', 'BoM Components')
bom_ids = fields.One2many('mrp.bom', 'product_tmpl_id', 'Bill of Materials')
bom_count = fields.Integer('# Bill of Material',
compute='_compute_bom_count', compute_sudo=False)
used_in_bom_count = fields.Integer('# of BoM Where is Used',
compute='_compute_used_in_bom_count', compute_sudo=False)
mrp_product_qty = fields.Float('Manufactured', digits='Product Unit of Measure',
compute='_compute_mrp_product_qty', compute_sudo=False)
produce_delay = fields.Float(
'Manufacturing Lead Time', default=0.0,
help="Average lead time in days to manufacture this product. In the case of multi-level BOM, the manufacturing lead times of the components will be added. In case the product is subcontracted, this can be used to determine the date at which components should be sent to the subcontractor.")
is_kits = fields.Boolean(compute='_compute_is_kits', search='_search_is_kits')
days_to_prepare_mo = fields.Float(
string="Days to prepare Manufacturing Order", default=0.0,
help="Create and confirm Manufacturing Orders this many days in advance, to have enough time to replenish components or manufacture semi-finished products.\n"
"Note that security lead times will also be considered when appropriate.")
def _compute_bom_count(self):
for product in self:
product.bom_count = self.env['mrp.bom'].search_count(['|', ('product_tmpl_id', '=', product.id), ('byproduct_ids.product_id.product_tmpl_id', '=', product.id)])
@api.depends_context('company')
def _compute_is_kits(self):
domain = [('company_id', 'in', [False, self.env.company.id]),
('product_tmpl_id', 'in', self.ids),
('active', '=', True),
('type', '=', 'phantom')]
bom_mapping = self.env['mrp.bom'].sudo()._read_group(
domain, ['product_tmpl_id'], ['product_tmpl_id'], orderby='id')
kits_ids = {b['product_tmpl_id'][0] for b in bom_mapping}
for template in self:
template.is_kits = (template.id in kits_ids)
def _search_is_kits(self, operator, value):
assert operator in ('=', '!='), 'Unsupported operator'
bom_tmpl_query = self.env['mrp.bom'].sudo()._search(
[('company_id', 'in', [False] + self.env.companies.ids),
('type', '=', 'phantom'), ('active', '=', True)])
neg = ''
if (operator == '=' and not value) or (operator == '!=' and value):
neg = 'not '
return [('id', neg + 'inselect', bom_tmpl_query.subselect('product_tmpl_id'))]
def _compute_show_qty_status_button(self):
super()._compute_show_qty_status_button()
for template in self:
if template.is_kits:
template.show_on_hand_qty_status_button = template.product_variant_count <= 1
template.show_forecasted_qty_status_button = False
def _compute_used_in_bom_count(self):
for template in self:
template.used_in_bom_count = self.env['mrp.bom'].search_count(
[('bom_line_ids.product_tmpl_id', '=', template.id)])
def write(self, values):
if 'active' in values:
self.filtered(lambda p: p.active != values['active']).with_context(active_test=False).bom_ids.write({
'active': values['active']
})
return super().write(values)
def action_used_in_bom(self):
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id("mrp.mrp_bom_form_action")
action['domain'] = [('bom_line_ids.product_tmpl_id', '=', self.id)]
return action
def _compute_mrp_product_qty(self):
for template in self:
template.mrp_product_qty = float_round(sum(template.mapped('product_variant_ids').mapped('mrp_product_qty')), precision_rounding=template.uom_id.rounding)
def action_view_mos(self):
action = self.env["ir.actions.actions"]._for_xml_id("mrp.mrp_production_report")
action['domain'] = [('state', '=', 'done'), ('product_tmpl_id', 'in', self.ids)]
action['context'] = {
'graph_measure': 'product_uom_qty',
'search_default_filter_plan_date': 1,
}
return action
def action_compute_bom_days(self):
templates = self.filtered(lambda t: t.bom_count > 0)
if templates:
return templates.mapped('product_variant_id').action_compute_bom_days()
def action_archive(self):
filtered_products = self.env['mrp.bom.line'].search([('product_id', 'in', self.product_variant_ids.ids), ('bom_id.active', '=', True)]).product_id.mapped('display_name')
res = super().action_archive()
if filtered_products:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _("Note that product(s): '%s' is/are still linked to active Bill of Materials, "
"which means that the product can still be used on it/them.", filtered_products),
'type': 'warning',
'sticky': True, #True/False will display for few seconds if false
'next': {'type': 'ir.actions.act_window_close'},
},
}
return res
class ProductProduct(models.Model):
_inherit = "product.product"
variant_bom_ids = fields.One2many('mrp.bom', 'product_id', 'BOM Product Variants')
bom_line_ids = fields.One2many('mrp.bom.line', 'product_id', 'BoM Components')
bom_count = fields.Integer('# Bill of Material',
compute='_compute_bom_count', compute_sudo=False)
used_in_bom_count = fields.Integer('# BoM Where Used',
compute='_compute_used_in_bom_count', compute_sudo=False)
mrp_product_qty = fields.Float('Manufactured', digits='Product Unit of Measure',
compute='_compute_mrp_product_qty', compute_sudo=False)
is_kits = fields.Boolean(compute="_compute_is_kits", search='_search_is_kits')
def _compute_bom_count(self):
for product in self:
product.bom_count = self.env['mrp.bom'].search_count(['|', '|', ('byproduct_ids.product_id', '=', product.id), ('product_id', '=', product.id), '&', ('product_id', '=', False), ('product_tmpl_id', '=', product.product_tmpl_id.id)])
@api.depends_context('company')
def _compute_is_kits(self):
domain = [
'&', ('company_id', 'in', [False, self.env.company.id]),
'&', ('active', '=', True),
'&', ('type', '=', 'phantom'),
'|', ('product_id', 'in', self.ids),
'&', ('product_id', '=', False),
('product_tmpl_id', 'in', self.product_tmpl_id.ids)]
tmpl_bom_mapping = self.env['mrp.bom'].sudo()._read_group(
domain, ['product_tmpl_id'], ['product_tmpl_id'], orderby='id')
product_bom_mapping = self.env['mrp.bom'].sudo()._read_group(
domain, ['product_id'], ['product_id'], orderby='id')
kits_template_ids = {b['product_tmpl_id'][0] for b in tmpl_bom_mapping}
kits_product_ids = {b['product_id'][0] for b in product_bom_mapping if b['product_id']}
for product in self:
product.is_kits = (product.id in kits_product_ids or product.product_tmpl_id.id in kits_template_ids)
def _search_is_kits(self, operator, value):
assert operator in ('=', '!='), 'Unsupported operator'
bom_tmpl_query = self.env['mrp.bom'].sudo()._search(
[('company_id', 'in', [False] + self.env.companies.ids),
('active', '=', True),
('type', '=', 'phantom'), ('product_id', '=', False)])
bom_product_query = self.env['mrp.bom'].sudo()._search(
[('company_id', 'in', [False] + self.env.companies.ids),
('type', '=', 'phantom'), ('product_id', '!=', False)])
neg = ''
op = '|'
if (operator == '=' and not value) or (operator == '!=' and value):
neg = 'not '
op = '&'
return [op, ('product_tmpl_id', neg + 'inselect', bom_tmpl_query.subselect('product_tmpl_id')),
('id', neg + 'inselect', bom_product_query.subselect('product_id'))]
def _compute_show_qty_status_button(self):
super()._compute_show_qty_status_button()
for product in self:
if product.is_kits:
product.show_on_hand_qty_status_button = True
product.show_forecasted_qty_status_button = False
def _compute_used_in_bom_count(self):
for product in self:
product.used_in_bom_count = self.env['mrp.bom'].search_count([('bom_line_ids.product_id', '=', product.id)])
def write(self, values):
if 'active' in values:
self.filtered(lambda p: p.active != values['active']).with_context(active_test=False).variant_bom_ids.write({
'active': values['active']
})
return super().write(values)
def get_components(self):
""" Return the components list ids in case of kit product.
Return the product itself otherwise"""
self.ensure_one()
bom_kit = self.env['mrp.bom']._bom_find(self, bom_type='phantom')[self]
if bom_kit:
boms, bom_sub_lines = bom_kit.explode(self, 1)
return [bom_line.product_id.id for bom_line, data in bom_sub_lines if bom_line.product_id.type == 'product']
else:
return super(ProductProduct, self).get_components()
def action_used_in_bom(self):
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id("mrp.mrp_bom_form_action")
action['domain'] = [('bom_line_ids.product_id', '=', self.id)]
return action
def _compute_mrp_product_qty(self):
date_from = fields.Datetime.to_string(fields.datetime.now() - timedelta(days=365))
#TODO: state = done?
domain = [('state', '=', 'done'), ('product_id', 'in', self.ids), ('date_planned_start', '>', date_from)]
read_group_res = self.env['mrp.production']._read_group(domain, ['product_id', 'product_uom_qty'], ['product_id'])
mapped_data = dict([(data['product_id'][0], data['product_uom_qty']) for data in read_group_res])
for product in self:
if not product.id:
product.mrp_product_qty = 0.0
continue
product.mrp_product_qty = float_round(mapped_data.get(product.id, 0), precision_rounding=product.uom_id.rounding)
def _compute_quantities_dict(self, lot_id, owner_id, package_id, from_date=False, to_date=False):
""" When the product is a kit, this override computes the fields :
- 'virtual_available'
- 'qty_available'
- 'incoming_qty'
- 'outgoing_qty'
- 'free_qty'
This override is used to get the correct quantities of products
with 'phantom' as BoM type.
"""
bom_kits = self.env['mrp.bom']._bom_find(self, bom_type='phantom')
kits = self.filtered(lambda p: bom_kits.get(p))
regular_products = self - kits
res = (
super(ProductProduct, regular_products)._compute_quantities_dict(lot_id, owner_id, package_id, from_date=from_date, to_date=to_date)
if regular_products
else {}
)
qties = self.env.context.get("mrp_compute_quantities", {})
qties.update(res)
# pre-compute bom lines and identify missing kit components to prefetch
bom_sub_lines_per_kit = {}
prefetch_component_ids = set()
for product in bom_kits:
__, bom_sub_lines = bom_kits[product].explode(product, 1)
bom_sub_lines_per_kit[product] = bom_sub_lines
for bom_line, __ in bom_sub_lines:
if bom_line.product_id.id not in qties:
prefetch_component_ids.add(bom_line.product_id.id)
# compute kit quantities
for product in bom_kits:
bom_sub_lines = bom_sub_lines_per_kit[product]
# group lines by component
bom_sub_lines_grouped = collections.defaultdict(list)
for info in bom_sub_lines:
bom_sub_lines_grouped[info[0].product_id].append(info)
ratios_virtual_available = []
ratios_qty_available = []
ratios_incoming_qty = []
ratios_outgoing_qty = []
ratios_free_qty = []
for component, bom_sub_lines in bom_sub_lines_grouped.items():
component = component.with_context(mrp_compute_quantities=qties).with_prefetch(prefetch_component_ids)
qty_per_kit = 0
for bom_line, bom_line_data in bom_sub_lines:
if component.type != 'product' or float_is_zero(bom_line_data['qty'], precision_rounding=bom_line.product_uom_id.rounding):
# As BoMs allow components with 0 qty, a.k.a. optionnal components, we simply skip those
# to avoid a division by zero. The same logic is applied to non-storable products as those
# products have 0 qty available.
continue
uom_qty_per_kit = bom_line_data['qty'] / bom_line_data['original_qty']
qty_per_kit += bom_line.product_uom_id._compute_quantity(uom_qty_per_kit, bom_line.product_id.uom_id, round=False, raise_if_failure=False)
if not qty_per_kit:
continue
rounding = component.uom_id.rounding
component_res = (
qties.get(component.id)
if component.id in qties
else {
"virtual_available": float_round(component.virtual_available, precision_rounding=rounding),
"qty_available": float_round(component.qty_available, precision_rounding=rounding),
"incoming_qty": float_round(component.incoming_qty, precision_rounding=rounding),
"outgoing_qty": float_round(component.outgoing_qty, precision_rounding=rounding),
"free_qty": float_round(component.free_qty, precision_rounding=rounding),
}
)
ratios_virtual_available.append(float_round(component_res["virtual_available"] / qty_per_kit, precision_rounding=rounding, rounding_method='DOWN'))
ratios_qty_available.append(float_round(component_res["qty_available"] / qty_per_kit, precision_rounding=rounding, rounding_method='DOWN'))
ratios_incoming_qty.append(float_round(component_res["incoming_qty"] / qty_per_kit, precision_rounding=rounding, rounding_method='DOWN'))
ratios_outgoing_qty.append(float_round(component_res["outgoing_qty"] / qty_per_kit, precision_rounding=rounding, rounding_method='DOWN'))
ratios_free_qty.append(float_round(component_res["free_qty"] / qty_per_kit, precision_rounding=rounding, rounding_method='DOWN'))
if bom_sub_lines and ratios_virtual_available: # Guard against all cnsumable bom: at least one ratio should be present.
res[product.id] = {
'virtual_available': float_round(min(ratios_virtual_available) * bom_kits[product].product_qty, precision_rounding=rounding) // 1,
'qty_available': float_round(min(ratios_qty_available) * bom_kits[product].product_qty, precision_rounding=rounding) // 1,
'incoming_qty': float_round(min(ratios_incoming_qty) * bom_kits[product].product_qty, precision_rounding=rounding) // 1,
'outgoing_qty': float_round(min(ratios_outgoing_qty) * bom_kits[product].product_qty, precision_rounding=rounding) // 1,
'free_qty': float_round(min(ratios_free_qty) * bom_kits[product].product_qty, precision_rounding=rounding) // 1,
}
else:
res[product.id] = {
'virtual_available': 0,
'qty_available': 0,
'incoming_qty': 0,
'outgoing_qty': 0,
'free_qty': 0,
}
return res
def action_view_bom(self):
action = self.env["ir.actions.actions"]._for_xml_id("mrp.product_open_bom")
template_ids = self.mapped('product_tmpl_id').ids
# bom specific to this variant or global to template or that contains the product as a byproduct
action['context'] = {
'default_product_tmpl_id': template_ids[0],
'default_product_id': self.env.user.has_group('product.group_product_variant') and self.ids[0] or False,
}
action['domain'] = ['|', '|', ('byproduct_ids.product_id', 'in', self.ids), ('product_id', 'in', self.ids), '&', ('product_id', '=', False), ('product_tmpl_id', 'in', template_ids)]
return action
def action_view_mos(self):
action = self.product_tmpl_id.action_view_mos()
action['domain'] = [('state', '=', 'done'), ('product_id', 'in', self.ids)]
return action
def action_open_quants(self):
bom_kits = self.env['mrp.bom']._bom_find(self, bom_type='phantom')
components = self - self.env['product.product'].concat(*list(bom_kits.keys()))
for product in bom_kits:
boms, bom_sub_lines = bom_kits[product].explode(product, 1)
components |= self.env['product.product'].concat(*[l[0].product_id for l in bom_sub_lines])
res = super(ProductProduct, components).action_open_quants()
if bom_kits:
res['context']['single_product'] = False
res['context'].pop('default_product_tmpl_id', None)
return res
def action_compute_bom_days(self):
bom_by_products = self.env['mrp.bom']._bom_find(self)
company_id = self.env.context.get('default_company_id', self.env.company.id)
warehouse = self.env['stock.warehouse'].search([('company_id', '=', company_id)], limit=1)
for product in self:
bom_data = self.env['report.mrp.report_bom_structure'].with_context(minimized=True)._get_bom_data(bom_by_products[product], warehouse, product, ignore_stock=True)
if bom_data.get('availability_state') == 'unavailable' and not bom_data.get('components_available', True):
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Cannot compute days to prepare due to missing route info for at least 1 component or for the final product.'),
'sticky': False,
}
}
availability_delay = bom_data.get('resupply_avail_delay')
product.days_to_prepare_mo = availability_delay - bom_data.get('lead_time', 0) if availability_delay else 0
def _match_all_variant_values(self, product_template_attribute_value_ids):
""" It currently checks that all variant values (`product_template_attribute_value_ids`)
are in the product (`self`).
If multiple values are encoded for the same attribute line, only one of
them has to be found on the variant.
"""
self.ensure_one()
# The intersection of the values of the product and those of the line satisfy:
# * the number of items equals the number of attributes (since a product cannot
# have multiple values for the same attribute),
# * the attributes are a subset of the attributes of the line.
return len(self.product_template_attribute_value_ids & product_template_attribute_value_ids) == len(product_template_attribute_value_ids.attribute_id)
def _count_returned_sn_products(self, sn_lot):
res = self.env['stock.move.line'].search_count([
('lot_id', '=', sn_lot.id),
('qty_done', '=', 1),
('state', '=', 'done'),
('production_id', '=', False),
('location_id.usage', '=', 'production'),
('move_id.unbuild_id', '!=', False),
])
return super()._count_returned_sn_products(sn_lot) + res
def _search_qty_available_new(self, operator, value, lot_id=False, owner_id=False, package_id=False):
'''extending the method in stock.product to take into account kits'''
product_ids = super(ProductProduct, self)._search_qty_available_new(operator, value, lot_id, owner_id, package_id)
kit_boms = self.env['mrp.bom'].search([('type', "=", 'phantom')])
kit_products = self.env['product.product']
for kit in kit_boms:
if kit.product_id:
kit_products |= kit.product_id
else:
kit_products |= kit.product_tmpl_id.product_variant_ids
for product in kit_products:
if OPERATORS[operator](product.qty_available, value):
product_ids.append(product.id)
return list(set(product_ids))
def action_archive(self):
filtered_products = self.env['mrp.bom.line'].search([('product_id', 'in', self.ids), ('bom_id.active', '=', True)]).product_id.mapped('display_name')
res = super().action_archive()
if filtered_products:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _("Note that product(s): '%s' is/are still linked to active Bill of Materials, "
"which means that the product can still be used on it/them.", filtered_products),
'type': 'warning',
'sticky': True, #True/False will display for few seconds if false
'next': {'type': 'ir.actions.act_window_close'},
},
}
return res

View file

@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class Company(models.Model):
_inherit = 'res.company'
manufacturing_lead = fields.Float(
'Manufacturing Lead Time', default=0.0, required=True,
help="Security days for each manufacturing operation.")
def _create_unbuild_sequence(self):
unbuild_vals = []
for company in self:
unbuild_vals.append({
'name': 'Unbuild',
'code': 'mrp.unbuild',
'company_id': company.id,
'prefix': 'UB/',
'padding': 5,
'number_next': 1,
'number_increment': 1
})
if unbuild_vals:
self.env['ir.sequence'].create(unbuild_vals)
@api.model
def create_missing_unbuild_sequences(self):
company_ids = self.env['res.company'].search([])
company_has_unbuild_seq = self.env['ir.sequence'].search([('code', '=', 'mrp.unbuild')]).mapped('company_id')
company_todo_sequence = company_ids - company_has_unbuild_seq
company_todo_sequence._create_unbuild_sequence()
def _create_per_company_sequences(self):
super(Company, self)._create_per_company_sequences()
self._create_unbuild_sequence()
def _get_security_by_rule_action(self):
res = super()._get_security_by_rule_action()
res['manufacture'] = self.manufacturing_lead
return res

View file

@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
manufacturing_lead = fields.Float(related='company_id.manufacturing_lead', string="Manufacturing Lead Time", readonly=False)
use_manufacturing_lead = fields.Boolean(string="Default Manufacturing Lead Time", config_parameter='mrp.use_manufacturing_lead')
group_mrp_byproducts = fields.Boolean("By-Products",
implied_group='mrp.group_mrp_byproducts')
module_mrp_mps = fields.Boolean("Master Production Schedule")
module_mrp_plm = fields.Boolean("Product Lifecycle Management (PLM)")
module_mrp_workorder = fields.Boolean("Work Orders")
module_quality_control = fields.Boolean("Quality")
module_quality_control_worksheet = fields.Boolean("Quality Worksheet")
module_mrp_subcontracting = fields.Boolean("Subcontracting")
group_mrp_routings = fields.Boolean("MRP Work Orders",
implied_group='mrp.group_mrp_routings')
group_unlocked_by_default = fields.Boolean("Unlock Manufacturing Orders", implied_group='mrp.group_unlocked_by_default')
group_mrp_reception_report = fields.Boolean("Allocation Report for Manufacturing Orders", implied_group='mrp.group_mrp_reception_report')
group_mrp_workorder_dependencies = fields.Boolean("Work Order Dependencies", implied_group="mrp.group_mrp_workorder_dependencies")
def set_values(self):
routing_before = self.env.user.has_group('mrp.group_mrp_routings')
super().set_values()
if routing_before and not self.group_mrp_routings:
self.env['mrp.routing.workcenter'].search([]).active = False
elif not routing_before and self.group_mrp_routings:
operations = self.env['mrp.routing.workcenter'].search_read([('active', '=', False)], ['id', 'write_date'])
last_updated = max((op['write_date'] for op in operations), default=0)
if last_updated:
op_to_update = self.env['mrp.routing.workcenter'].browse([op['id'] for op in operations if op['write_date'] == last_updated])
op_to_update.active = True
if not self.group_mrp_workorder_dependencies:
# Disabling this option should not interfere with currently planned productions
self.env['mrp.bom'].sudo().search([('allow_operation_dependencies', '=', True)]).allow_operation_dependencies = False
@api.onchange('use_manufacturing_lead')
def _onchange_use_manufacturing_lead(self):
if not self.use_manufacturing_lead:
self.manufacturing_lead = 0.0
@api.onchange('group_mrp_routings')
def _onchange_group_mrp_routings(self):
# If we activate 'MRP Work Orders', it means that we need to install 'mrp_workorder'.
# The opposite is not always true: other modules (such as 'quality_mrp_workorder') may
# depend on 'mrp_workorder', so we should not automatically uninstall the module if 'MRP
# Work Orders' is deactivated.
# Long story short: if 'mrp_workorder' is already installed, we don't uninstall it based on
# group_mrp_routings
if self.group_mrp_routings:
self.module_mrp_workorder = True
else:
self.module_mrp_workorder = False
@api.onchange('group_unlocked_by_default')
def _onchange_group_unlocked_by_default(self):
""" When changing this setting, we want existing MOs to automatically update to match setting. """
if self.group_unlocked_by_default:
self.env['mrp.production'].search([('state', 'not in', ('cancel', 'done')), ('is_locked', '=', True)]).is_locked = False
else:
self.env['mrp.production'].search([('state', 'not in', ('cancel', 'done')), ('is_locked', '=', False)]).is_locked = True

View file

@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, _
from odoo.exceptions import UserError
class StockLot(models.Model):
_inherit = 'stock.lot'
def _check_create(self):
active_mo_id = self.env.context.get('active_mo_id')
if active_mo_id:
active_mo = self.env['mrp.production'].browse(active_mo_id)
if not active_mo.picking_type_id.use_create_components_lots:
raise UserError(_('You are not allowed to create or edit a lot or serial number for the components with the operation type "Manufacturing". To change this, go on the operation type and tick the box "Create New Lots/Serial Numbers for Components".'))
return super()._check_create()

View file

@ -0,0 +1,611 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from dateutil.relativedelta import relativedelta
from odoo import api, Command, fields, models
from odoo.osv import expression
from odoo.tools import float_compare, float_round, float_is_zero, OrderedSet
class StockMoveLine(models.Model):
_inherit = 'stock.move.line'
workorder_id = fields.Many2one('mrp.workorder', 'Work Order', check_company=True)
production_id = fields.Many2one('mrp.production', 'Production Order', check_company=True)
description_bom_line = fields.Char(related='move_id.description_bom_line')
@api.depends('production_id')
def _compute_picking_type_id(self):
line_to_remove = self.env['stock.move.line']
for line in self:
if not line.production_id:
continue
line.picking_type_id = line.production_id.picking_type_id
line_to_remove |= line
return super(StockMoveLine, self - line_to_remove)._compute_picking_type_id()
def _search_picking_type_id(self, operator, value):
res = super()._search_picking_type_id(operator=operator, value=value)
if operator in ['not in', '!=', 'not ilike']:
if value is False:
return expression.OR([[('production_id.picking_type_id', operator, value)], res])
else:
return expression.AND([[('production_id.picking_type_id', operator, value)], res])
else:
if value is False:
return expression.AND([[('production_id.picking_type_id', operator, value)], res])
else:
return expression.OR([[('production_id.picking_type_id', operator, value)], res])
@api.model_create_multi
def create(self, values):
res = super(StockMoveLine, self).create(values)
for line in res:
# If the line is added in a done production, we need to map it
# manually to the produced move lines in order to see them in the
# traceability report
if line.move_id.raw_material_production_id and line.state == 'done':
mo = line.move_id.raw_material_production_id
finished_lots = mo.lot_producing_id
finished_lots |= mo.move_finished_ids.filtered(lambda m: m.product_id != mo.product_id).move_line_ids.lot_id
if finished_lots:
produced_move_lines = mo.move_finished_ids.move_line_ids.filtered(lambda sml: sml.lot_id in finished_lots)
line.produce_line_ids = [(6, 0, produced_move_lines.ids)]
else:
produced_move_lines = mo.move_finished_ids.move_line_ids
line.produce_line_ids = [(6, 0, produced_move_lines.ids)]
return res
def _get_similar_move_lines(self):
lines = super(StockMoveLine, self)._get_similar_move_lines()
if self.move_id.production_id:
finished_moves = self.move_id.production_id.move_finished_ids
finished_move_lines = finished_moves.mapped('move_line_ids')
lines |= finished_move_lines.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name))
if self.move_id.raw_material_production_id:
raw_moves = self.move_id.raw_material_production_id.move_raw_ids
raw_moves_lines = raw_moves.mapped('move_line_ids')
lines |= raw_moves_lines.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name))
return lines
def _reservation_is_updatable(self, quantity, reserved_quant):
self.ensure_one()
if self.produce_line_ids.lot_id:
ml_remaining_qty = self.qty_done - self.reserved_uom_qty
ml_remaining_qty = self.product_uom_id._compute_quantity(ml_remaining_qty, self.product_id.uom_id, rounding_method="HALF-UP")
if float_compare(ml_remaining_qty, quantity, precision_rounding=self.product_id.uom_id.rounding) < 0:
return False
return super(StockMoveLine, self)._reservation_is_updatable(quantity, reserved_quant)
def write(self, vals):
for move_line in self:
production = move_line.move_id.production_id or move_line.move_id.raw_material_production_id
if production and move_line.state == 'done' and any(field in vals for field in ('lot_id', 'location_id', 'qty_done')):
move_line._log_message(production, move_line, 'mrp.track_production_move_template', vals)
return super(StockMoveLine, self).write(vals)
def _get_aggregated_properties(self, move_line=False, move=False):
aggregated_properties = super()._get_aggregated_properties(move_line, move)
bom = aggregated_properties['move'].bom_line_id.bom_id
aggregated_properties['bom'] = bom or False
aggregated_properties['line_key'] += f'_{bom.id if bom else ""}'
return aggregated_properties
def _get_aggregated_product_quantities(self, **kwargs):
"""Returns dictionary of products and corresponding values of interest grouped by optional kit_name
Removes descriptions where description == kit_name. kit_name is expected to be passed as a
kwargs value because this is not directly stored in move_line_ids. Unfortunately because we
are working with aggregated data, we have to loop through the aggregation to do this removal.
arguments: kit_name (optional): string value of a kit name passed as a kwarg
returns: dictionary {same_key_as_super: {same_values_as_super, ...}
"""
aggregated_move_lines = super()._get_aggregated_product_quantities(**kwargs)
kit_name = kwargs.get('kit_name')
to_be_removed = []
for aggregated_move_line in aggregated_move_lines:
bom = aggregated_move_lines[aggregated_move_line]['bom']
is_phantom = bom.type == 'phantom' if bom else False
if kit_name:
product = bom.product_id or bom.product_tmpl_id if bom else False
display_name = product.display_name if product else False
description = aggregated_move_lines[aggregated_move_line]['description']
if not is_phantom or display_name != kit_name:
to_be_removed.append(aggregated_move_line)
elif description == kit_name:
aggregated_move_lines[aggregated_move_line]['description'] = ""
elif not kwargs and is_phantom:
to_be_removed.append(aggregated_move_line)
for move_line in to_be_removed:
del aggregated_move_lines[move_line]
return aggregated_move_lines
class StockMove(models.Model):
_inherit = 'stock.move'
created_production_id = fields.Many2one('mrp.production', 'Created Production Order', check_company=True, index=True)
production_id = fields.Many2one(
'mrp.production', 'Production Order for finished products', check_company=True, index='btree_not_null')
raw_material_production_id = fields.Many2one(
'mrp.production', 'Production Order for components', check_company=True, index='btree_not_null')
unbuild_id = fields.Many2one(
'mrp.unbuild', 'Disassembly Order', check_company=True)
consume_unbuild_id = fields.Many2one(
'mrp.unbuild', 'Consumed Disassembly Order', check_company=True)
allowed_operation_ids = fields.One2many(
'mrp.routing.workcenter', related='raw_material_production_id.bom_id.operation_ids')
operation_id = fields.Many2one(
'mrp.routing.workcenter', 'Operation To Consume', check_company=True,
domain="[('id', 'in', allowed_operation_ids)]")
workorder_id = fields.Many2one(
'mrp.workorder', 'Work Order To Consume', copy=False, check_company=True)
# Quantities to process, in normalized UoMs
bom_line_id = fields.Many2one('mrp.bom.line', 'BoM Line', check_company=True)
byproduct_id = fields.Many2one(
'mrp.bom.byproduct', 'By-products', check_company=True,
help="By-product line that generated the move in a manufacturing order")
unit_factor = fields.Float('Unit Factor', compute='_compute_unit_factor', store=True)
is_done = fields.Boolean(
'Done', compute='_compute_is_done', store=True)
order_finished_lot_id = fields.Many2one('stock.lot', string="Finished Lot/Serial Number", related="raw_material_production_id.lot_producing_id", store=True, index='btree_not_null')
should_consume_qty = fields.Float('Quantity To Consume', compute='_compute_should_consume_qty', digits='Product Unit of Measure')
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. The total of all by-products' cost share must be smaller or equal to 100.")
product_qty_available = fields.Float('Product On Hand Quantity', related='product_id.qty_available', depends=['product_id'])
product_virtual_available = fields.Float('Product Forecasted Quantity', related='product_id.virtual_available', depends=['product_id'])
description_bom_line = fields.Char('Kit', compute='_compute_description_bom_line')
manual_consumption = fields.Boolean(
'Manual Consumption', compute='_compute_manual_consumption', store=True,
help="When activated, then the registration of consumption for that component is recorded manually exclusively.\n"
"If not activated, and any of the components consumption is edited manually on the manufacturing order, Odoo assumes manual consumption also.")
@api.depends('state', 'product_id', 'operation_id')
def _compute_manual_consumption(self):
for move in self:
if move.state != 'draft':
continue
move.manual_consumption = move._is_manual_consumption()
@api.depends('bom_line_id')
def _compute_description_bom_line(self):
bom_line_description = {}
for bom in self.bom_line_id.bom_id:
if bom.type != 'phantom':
continue
line_ids = bom.bom_line_ids.ids
total = len(line_ids)
name = bom.display_name
for i, line_id in enumerate(line_ids):
bom_line_description[line_id] = '%s - %d/%d' % (name, i+1, total)
for move in self:
move.description_bom_line = bom_line_description.get(move.bom_line_id.id)
@api.depends('raw_material_production_id.priority')
def _compute_priority(self):
super()._compute_priority()
for move in self:
move.priority = move.raw_material_production_id.priority or move.priority or '0'
@api.depends('raw_material_production_id.picking_type_id', 'production_id.picking_type_id')
def _compute_picking_type_id(self):
super()._compute_picking_type_id()
for move in self:
if move.raw_material_production_id or move.production_id:
move.picking_type_id = (move.raw_material_production_id or move.production_id).picking_type_id
@api.depends('raw_material_production_id.is_locked', 'production_id.is_locked')
def _compute_is_locked(self):
super(StockMove, self)._compute_is_locked()
for move in self:
if move.raw_material_production_id:
move.is_locked = move.raw_material_production_id.is_locked
if move.production_id:
move.is_locked = move.production_id.is_locked
@api.depends('state')
def _compute_is_done(self):
for move in self:
move.is_done = (move.state in ('done', 'cancel'))
@api.depends('product_uom_qty',
'raw_material_production_id', 'raw_material_production_id.product_qty', 'raw_material_production_id.qty_produced',
'production_id', 'production_id.product_qty', 'production_id.qty_produced')
def _compute_unit_factor(self):
for move in self:
mo = move.raw_material_production_id or move.production_id
if mo:
move.unit_factor = move.product_uom_qty / ((mo.product_qty - mo.qty_produced) or 1)
else:
move.unit_factor = 1.0
@api.depends('raw_material_production_id', 'raw_material_production_id.name', 'production_id', 'production_id.name')
def _compute_reference(self):
moves_with_reference = self.env['stock.move']
for move in self:
if move.raw_material_production_id and move.raw_material_production_id.name:
move.reference = move.raw_material_production_id.name
moves_with_reference |= move
if move.production_id and move.production_id.name:
move.reference = move.production_id.name
moves_with_reference |= move
super(StockMove, self - moves_with_reference)._compute_reference()
@api.depends('raw_material_production_id.qty_producing', 'product_uom_qty', 'product_uom')
def _compute_should_consume_qty(self):
for move in self:
mo = move.raw_material_production_id
if not mo or not move.product_uom:
move.should_consume_qty = 0
continue
move.should_consume_qty = float_round((mo.qty_producing - mo.qty_produced) * move.unit_factor, precision_rounding=move.product_uom.rounding)
@api.onchange('product_uom_qty')
def _onchange_product_uom_qty(self):
if self.raw_material_production_id and self.has_tracking == 'none':
mo = self.raw_material_production_id
self._update_quantity_done(mo)
@api.model
def default_get(self, fields_list):
defaults = super(StockMove, self).default_get(fields_list)
if self.env.context.get('default_raw_material_production_id') or self.env.context.get('default_production_id'):
production_id = self.env['mrp.production'].browse(self.env.context.get('default_raw_material_production_id') or self.env.context.get('default_production_id'))
if production_id.state not in ('draft', 'cancel'):
if production_id.state != 'done':
defaults['state'] = 'draft'
else:
defaults['state'] = 'done'
defaults['additional'] = True
defaults['product_uom_qty'] = 0.0
elif production_id.state == 'draft':
defaults['group_id'] = production_id.procurement_group_id.id
defaults['reference'] = production_id.name
return defaults
@api.model_create_multi
def create(self, vals_list):
""" Enforce consistent values (i.e. match _get_move_raw_values/_get_move_finished_values) for:
- Manually added components/byproducts specifically values we can't set via view with "default_"
- Moves from a copied MO
- Backorders
"""
if self.env.context.get('force_manual_consumption'):
for vals in vals_list:
vals['manual_consumption'] = True
mo_id_to_mo = defaultdict(lambda: self.env['mrp.production'])
product_id_to_product = defaultdict(lambda: self.env['product.product'])
for values in vals_list:
mo_id = values.get('raw_material_production_id', False) or values.get('production_id', False)
location_dest = self.env['stock.location'].browse(values.get('location_dest_id'))
if mo_id and not values.get('scrapped') and not location_dest.scrap_location:
mo = mo_id_to_mo[mo_id]
if not mo:
mo = mo.browse(mo_id)
mo_id_to_mo[mo_id] = mo
values['name'] = mo.name
values['origin'] = mo._get_origin()
values['group_id'] = mo.procurement_group_id.id
values['propagate_cancel'] = mo.propagate_cancel
if values.get('raw_material_production_id', False):
product = product_id_to_product[values['product_id']]
if not product:
product = product.browse(values['product_id'])
product_id_to_product[values['product_id']] = product
values['location_dest_id'] = mo.production_location_id.id
values['price_unit'] = product.standard_price
if not values.get('location_id'):
values['location_id'] = mo.location_src_id.id
continue
# produced products + byproducts
values['location_id'] = mo.production_location_id.id
values['date'] = mo._get_date_planned_finished()
values['date_deadline'] = mo.date_deadline
if not values.get('location_dest_id'):
values['location_dest_id'] = mo.location_dest_id.id
return super().create(vals_list)
def write(self, vals):
if self.env.context.get('force_manual_consumption'):
vals['manual_consumption'] = True
if 'product_uom_qty' in vals:
if 'move_line_ids' in vals:
# first update lines then product_uom_qty as the later will unreserve
# so possibly unlink lines
move_line_vals = vals.pop('move_line_ids')
super().write({'move_line_ids': move_line_vals})
procurement_requests = []
for move in self:
if move.raw_material_production_id.state != 'confirmed' \
or not float_is_zero(move.product_uom_qty, precision_rounding=move.product_uom.rounding) \
or move.procure_method != 'make_to_order':
continue
values = move._prepare_procurement_values()
origin = move._prepare_procurement_origin()
procurement_requests.append(self.env['procurement.group'].Procurement(
move.product_id, vals['product_uom_qty'], move.product_uom,
move.location_id, move.rule_id and move.rule_id.name or "/",
origin, move.company_id, values))
self.env['procurement.group'].run(procurement_requests)
return super().write(vals)
def _action_assign(self, force_qty=False):
res = super(StockMove, self)._action_assign(force_qty=force_qty)
for move in self.filtered(lambda x: x.production_id or x.raw_material_production_id):
if move.move_line_ids:
move.move_line_ids.write({'production_id': move.raw_material_production_id.id,
'workorder_id': move.workorder_id.id,})
return res
def _action_confirm(self, merge=True, merge_into=False):
moves = self.action_explode()
merge_into = merge_into and merge_into.action_explode()
# we go further with the list of ids potentially changed by action_explode
return super(StockMove, moves)._action_confirm(merge=merge, merge_into=merge_into)
def action_explode(self):
""" Explodes pickings """
# in order to explode a move, we must have a picking_type_id on that move because otherwise the move
# won't be assigned to a picking and it would be weird to explode a move into several if they aren't
# all grouped in the same picking.
moves_ids_to_return = OrderedSet()
moves_ids_to_unlink = OrderedSet()
phantom_moves_vals_list = []
for move in self:
if not move.picking_type_id or (move.production_id and move.production_id.product_id == move.product_id):
moves_ids_to_return.add(move.id)
continue
bom = self.env['mrp.bom'].sudo()._bom_find(move.product_id, company_id=move.company_id.id, bom_type='phantom')[move.product_id]
if not bom:
moves_ids_to_return.add(move.id)
continue
if move.picking_id.immediate_transfer or float_is_zero(move.product_uom_qty, precision_rounding=move.product_uom.rounding):
factor = move.product_uom._compute_quantity(move.quantity_done, bom.product_uom_id) / bom.product_qty
else:
factor = move.product_uom._compute_quantity(move.product_uom_qty, bom.product_uom_id) / bom.product_qty
boms, lines = bom.sudo().explode(move.product_id, factor, picking_type=bom.picking_type_id)
for bom_line, line_data in lines:
if move.picking_id.immediate_transfer or float_is_zero(move.product_uom_qty, precision_rounding=move.product_uom.rounding):
phantom_moves_vals_list += move._generate_move_phantom(bom_line, 0, line_data['qty'])
else:
phantom_moves_vals_list += move._generate_move_phantom(bom_line, line_data['qty'], 0)
# delete the move with original product which is not relevant anymore
moves_ids_to_unlink.add(move.id)
if phantom_moves_vals_list:
phantom_moves = self.env['stock.move'].create(phantom_moves_vals_list)
phantom_moves._adjust_procure_method()
moves_ids_to_return |= phantom_moves.action_explode().ids
move_to_unlink = self.env['stock.move'].browse(moves_ids_to_unlink).sudo()
move_to_unlink.quantity_done = 0
move_to_unlink._action_cancel()
move_to_unlink.unlink()
return self.env['stock.move'].browse(moves_ids_to_return)
def action_show_details(self):
self.ensure_one()
action = super().action_show_details()
if self.raw_material_production_id:
action['views'] = [(self.env.ref('mrp.view_stock_move_operations_raw').id, 'form')]
action['context']['show_destination_location'] = False
action['context']['force_manual_consumption'] = True
action['context']['active_mo_id'] = self.raw_material_production_id.id
elif self.production_id:
action['views'] = [(self.env.ref('mrp.view_stock_move_operations_finished').id, 'form')]
action['context']['show_source_location'] = False
action['context']['show_reserved_quantity'] = False
return action
def _action_cancel(self):
res = super(StockMove, self)._action_cancel()
mo_to_cancel = self.mapped('raw_material_production_id').filtered(lambda p: all(m.state == 'cancel' for m in p.move_raw_ids))
if mo_to_cancel:
mo_to_cancel._action_cancel()
return res
def _prepare_move_split_vals(self, qty):
defaults = super()._prepare_move_split_vals(qty)
defaults['workorder_id'] = False
return defaults
def _prepare_procurement_origin(self):
self.ensure_one()
if self.raw_material_production_id and self.raw_material_production_id.orderpoint_id:
return self.origin
return super()._prepare_procurement_origin()
def _prepare_phantom_move_values(self, bom_line, product_qty, quantity_done):
return {
'picking_id': self.picking_id.id if self.picking_id else False,
'product_id': bom_line.product_id.id,
'product_uom': bom_line.product_uom_id.id,
'product_uom_qty': product_qty,
'quantity_done': quantity_done,
'state': 'draft', # will be confirmed below
'name': self.name,
'bom_line_id': bom_line.id,
}
def _generate_move_phantom(self, bom_line, product_qty, quantity_done):
vals = []
if bom_line.product_id.type in ['product', 'consu']:
vals = self.copy_data(default=self._prepare_phantom_move_values(bom_line, product_qty, quantity_done))
if self.state == 'assigned':
for v in vals:
v['state'] = 'assigned'
return vals
def _is_consuming(self):
return super()._is_consuming() or self.picking_type_id.code == 'mrp_operation'
def _get_backorder_move_vals(self):
self.ensure_one()
return {
'state': 'draft' if self.state == 'draft' else 'confirmed',
'reservation_date': self.reservation_date,
'date_deadline': self.date_deadline,
'manual_consumption': self._is_manual_consumption(),
'move_orig_ids': [Command.link(m.id) for m in self.mapped('move_orig_ids')],
'move_dest_ids': [Command.link(m.id) for m in self.mapped('move_dest_ids')],
'procure_method': self.procure_method,
}
def _get_source_document(self):
res = super()._get_source_document()
return res or self.production_id or self.raw_material_production_id
def _get_upstream_documents_and_responsibles(self, visited):
if self.production_id and self.production_id.state not in ('done', 'cancel'):
return [(self.production_id, self.production_id.user_id, visited)]
else:
return super(StockMove, self)._get_upstream_documents_and_responsibles(visited)
def _delay_alert_get_documents(self):
res = super(StockMove, self)._delay_alert_get_documents()
productions = self.raw_material_production_id | self.production_id
return res + list(productions)
def _should_be_assigned(self):
res = super(StockMove, self)._should_be_assigned()
return bool(res and not (self.production_id or self.raw_material_production_id))
def _should_bypass_set_qty_producing(self):
if self.state in ('done', 'cancel'):
return True
# Do not update extra product quantities
if float_is_zero(self.product_uom_qty, precision_rounding=self.product_uom.rounding):
return True
if (not self.raw_material_production_id.use_auto_consume_components_lots and self.has_tracking != 'none') or self.manual_consumption or self._origin.manual_consumption:
return True
return False
def _should_bypass_reservation(self, forced_location=False):
res = super(StockMove, self)._should_bypass_reservation(
forced_location=forced_location)
return bool(res and not self.production_id)
def _key_assign_picking(self):
keys = super(StockMove, self)._key_assign_picking()
return keys + (self.created_production_id,)
@api.model
def _prepare_merge_moves_distinct_fields(self):
res = super()._prepare_merge_moves_distinct_fields()
res += ['created_production_id', 'cost_share']
if self.bom_line_id and ("phantom" in self.bom_line_id.bom_id.mapped('type')):
res.append('bom_line_id')
return res
@api.model
def _prepare_merge_negative_moves_excluded_distinct_fields(self):
return super()._prepare_merge_negative_moves_excluded_distinct_fields() + ['created_production_id']
def _compute_kit_quantities(self, product_id, kit_qty, kit_bom, filters):
""" Computes the quantity delivered or received when a kit is sold or purchased.
A ratio 'qty_processed/qty_needed' is computed for each component, and the lowest one is kept
to define the kit's quantity delivered or received.
:param product_id: The kit itself a.k.a. the finished product
:param kit_qty: The quantity from the order line
:param kit_bom: The kit's BoM
:param filters: Dict of lambda expression to define the moves to consider and the ones to ignore
:return: The quantity delivered or received
"""
qty_ratios = []
boms, bom_sub_lines = kit_bom.explode(product_id, kit_qty)
for bom_line, bom_line_data in bom_sub_lines:
# skip service since we never deliver them
if bom_line.product_id.type == 'service':
continue
if float_is_zero(bom_line_data['qty'], precision_rounding=bom_line.product_uom_id.rounding):
# As BoMs allow components with 0 qty, a.k.a. optionnal components, we simply skip those
# to avoid a division by zero.
continue
bom_line_moves = self.filtered(lambda m: m.bom_line_id == bom_line)
if bom_line_moves:
# We compute the quantities needed of each components to make one kit.
# Then, we collect every relevant moves related to a specific component
# to know how many are considered delivered.
uom_qty_per_kit = bom_line_data['qty'] / bom_line_data['original_qty']
qty_per_kit = bom_line.product_uom_id._compute_quantity(uom_qty_per_kit, bom_line.product_id.uom_id, round=False)
if not qty_per_kit:
continue
incoming_moves = bom_line_moves.filtered(filters['incoming_moves'])
outgoing_moves = bom_line_moves.filtered(filters['outgoing_moves'])
qty_processed = sum(incoming_moves.mapped('product_qty')) - sum(outgoing_moves.mapped('product_qty'))
# We compute a ratio to know how many kits we can produce with this quantity of that specific component
qty_ratios.append(float_round(qty_processed / qty_per_kit, precision_rounding=bom_line.product_id.uom_id.rounding))
else:
return 0.0
if qty_ratios:
# Now that we have every ratio by components, we keep the lowest one to know how many kits we can produce
# with the quantities delivered of each component. We use the floor division here because a 'partial kit'
# doesn't make sense.
return min(qty_ratios) // 1
else:
return 0.0
def _show_details_in_draft(self):
self.ensure_one()
production = self.raw_material_production_id or self.production_id
if production and (self.state != 'draft' or production.state != 'draft'):
return True
elif production:
return False
else:
return super()._show_details_in_draft()
def _update_quantity_done(self, mo):
self.ensure_one()
new_qty = float_round((mo.qty_producing - mo.qty_produced) * self.unit_factor, precision_rounding=self.product_uom.rounding)
if not self.is_quantity_done_editable:
self.move_line_ids.filtered(lambda ml: ml.state not in ('done', 'cancel')).qty_done = 0
self.move_line_ids = self._set_quantity_done_prepare_vals(new_qty)
else:
self.quantity_done = new_qty
def _update_candidate_moves_list(self, candidate_moves_list):
super()._update_candidate_moves_list(candidate_moves_list)
for production in self.mapped('raw_material_production_id'):
candidate_moves_list.append(production.move_raw_ids.filtered(lambda m: m.product_id in self.product_id))
for production in self.mapped('production_id'):
candidate_moves_list.append(production.move_finished_ids.filtered(lambda m: m.product_id in self.product_id))
def _multi_line_quantity_done_set(self, quantity_done):
if self.raw_material_production_id:
self.move_line_ids.filtered(lambda ml: ml.state not in ('done', 'cancel')).qty_done = 0
self.move_line_ids = self._set_quantity_done_prepare_vals(quantity_done)
else:
super()._multi_line_quantity_done_set(quantity_done)
def _prepare_procurement_values(self):
res = super()._prepare_procurement_values()
res['bom_line_id'] = self.bom_line_id.id
return res
def action_open_reference(self):
res = super().action_open_reference()
source = self.production_id or self.raw_material_production_id
if source and source.check_access_rights('read', raise_exception=False):
return {
'res_model': source._name,
'type': 'ir.actions.act_window',
'views': [[False, "form"]],
'res_id': source.id,
}
return res
def _is_manual_consumption(self):
self.ensure_one()
return self._determine_is_manual_consumption(self.product_id, self.raw_material_production_id, self.bom_line_id)
@api.model
def _determine_is_manual_consumption(self, product, production, bom_line):
return (product.product_tmpl_id.tracking != 'none' and not production.use_auto_consume_components_lots) or \
(product.product_tmpl_id.tracking == 'none' and bom_line and bom_line.manual_consumption)

View file

@ -0,0 +1,154 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models
from odoo.tools.float_utils import float_is_zero
from odoo.osv.expression import AND
from dateutil.relativedelta import relativedelta
class StockWarehouseOrderpoint(models.Model):
_inherit = 'stock.warehouse.orderpoint'
show_bom = fields.Boolean('Show BoM column', compute='_compute_show_bom')
bom_id = fields.Many2one(
'mrp.bom', string='Bill of Materials', check_company=True,
domain="[('type', '=', 'normal'), '&', '|', ('company_id', '=', company_id), ('company_id', '=', False), '|', ('product_id', '=', product_id), '&', ('product_id', '=', False), ('product_tmpl_id', '=', product_tmpl_id)]")
manufacturing_visibility_days = fields.Float(default=0.0, help="Visibility Days applied on the manufacturing routes.")
def _get_replenishment_order_notification(self):
self.ensure_one()
domain = [('orderpoint_id', 'in', self.ids)]
if self.env.context.get('written_after'):
domain = AND([domain, [('write_date', '>', self.env.context.get('written_after'))]])
production = self.env['mrp.production'].search(domain, limit=1)
if production:
action = self.env.ref('mrp.action_mrp_production_form')
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('The following replenishment order has been generated'),
'message': '%s',
'links': [{
'label': production.name,
'url': f'#action={action.id}&id={production.id}&model=mrp.production'
}],
'sticky': False,
'next': {'type': 'ir.actions.act_window_close'},
}
}
return super()._get_replenishment_order_notification()
@api.depends('route_id')
def _compute_show_bom(self):
manufacture_route = []
for res in self.env['stock.rule'].search_read([('action', '=', 'manufacture')], ['route_id']):
manufacture_route.append(res['route_id'][0])
for orderpoint in self:
orderpoint.show_bom = orderpoint.route_id.id in manufacture_route
def _compute_visibility_days(self):
res = super()._compute_visibility_days()
for orderpoint in self:
if 'manufacture' in orderpoint.rule_ids.mapped('action'):
orderpoint.visibility_days = orderpoint.manufacturing_visibility_days
return res
def _set_visibility_days(self):
res = super()._set_visibility_days()
for orderpoint in self:
if 'manufacture' in orderpoint.rule_ids.mapped('action'):
orderpoint.manufacturing_visibility_days = orderpoint.visibility_days
return res
def _compute_days_to_order(self):
res = super()._compute_days_to_order()
for orderpoint in self:
if 'manufacture' in orderpoint.rule_ids.mapped('action'):
orderpoint.days_to_order = orderpoint.product_id.days_to_prepare_mo
return res
def _quantity_in_progress(self):
bom_kits = self.env['mrp.bom']._bom_find(self.product_id, bom_type='phantom')
bom_kit_orderpoints = {
orderpoint: bom_kits[orderpoint.product_id]
for orderpoint in self
if orderpoint.product_id in bom_kits
}
orderpoints_without_kit = self - self.env['stock.warehouse.orderpoint'].concat(*bom_kit_orderpoints.keys())
res = super(StockWarehouseOrderpoint, orderpoints_without_kit)._quantity_in_progress()
for orderpoint in bom_kit_orderpoints:
dummy, bom_sub_lines = bom_kit_orderpoints[orderpoint].explode(orderpoint.product_id, 1)
ratios_qty_available = []
# total = qty_available + in_progress
ratios_total = []
for bom_line, bom_line_data in bom_sub_lines:
component = bom_line.product_id
if component.type != 'product' or float_is_zero(bom_line_data['qty'], precision_rounding=bom_line.product_uom_id.rounding):
continue
uom_qty_per_kit = bom_line_data['qty'] / bom_line_data['original_qty']
qty_per_kit = bom_line.product_uom_id._compute_quantity(uom_qty_per_kit, bom_line.product_id.uom_id, raise_if_failure=False)
if not qty_per_kit:
continue
qty_by_product_location, dummy = component._get_quantity_in_progress(orderpoint.location_id.ids)
qty_in_progress = qty_by_product_location.get((component.id, orderpoint.location_id.id), 0.0)
qty_available = component.qty_available / qty_per_kit
ratios_qty_available.append(qty_available)
ratios_total.append(qty_available + (qty_in_progress / qty_per_kit))
# For a kit, the quantity in progress is :
# (the quantity if we have received all in-progress components) - (the quantity using only available components)
product_qty = min(ratios_total or [0]) - min(ratios_qty_available or [0])
res[orderpoint.id] = orderpoint.product_id.uom_id._compute_quantity(product_qty, orderpoint.product_uom, round=False)
bom_manufacture = self.env['mrp.bom']._bom_find(orderpoints_without_kit.product_id, bom_type='normal')
bom_manufacture = self.env['mrp.bom'].concat(*bom_manufacture.values())
productions_group = self.env['mrp.production'].read_group(
[('bom_id', 'in', bom_manufacture.ids), ('state', '=', 'draft'), ('orderpoint_id', 'in', orderpoints_without_kit.ids)],
['orderpoint_id', 'product_qty', 'product_uom_id'],
['orderpoint_id', 'product_uom_id'], lazy=False)
for p in productions_group:
uom = self.env['uom.uom'].browse(p['product_uom_id'][0])
orderpoint = self.env['stock.warehouse.orderpoint'].browse(p['orderpoint_id'][0])
res[orderpoint.id] += uom._compute_quantity(
p['product_qty'], orderpoint.product_uom, round=False)
return res
def _get_qty_multiple_to_order(self):
""" Calculates the minimum quantity that can be ordered according to the qty and UoM of the BoM
"""
self.ensure_one()
qty_multiple_to_order = super()._get_qty_multiple_to_order()
if 'manufacture' in self.rule_ids.mapped('action'):
bom = self.env['mrp.bom']._bom_find(self.product_id, bom_type='normal')[self.product_id]
return bom.product_uom_id._compute_quantity(bom.product_qty, self.product_uom)
return qty_multiple_to_order
def _set_default_route_id(self):
route_ids = self.env['stock.rule'].search([
('action', '=', 'manufacture')
]).route_id
for orderpoint in self:
if not orderpoint.product_id.bom_ids:
continue
route_id = orderpoint.rule_ids.route_id & route_ids
if not route_id:
continue
orderpoint.route_id = route_id[0].id
return super()._set_default_route_id()
def _prepare_procurement_values(self, date=False, group=False):
values = super()._prepare_procurement_values(date=date, group=group)
values['bom_id'] = self.bom_id
return values
def _post_process_scheduler(self):
""" Confirm the productions only after all the orderpoints have run their
procurement to avoid the new procurement created from the production conflict
with them. """
self.env['mrp.production'].sudo().search([
('orderpoint_id', 'in', self.ids),
('move_raw_ids', '!=', False),
('state', '=', 'draft'),
]).action_confirm()
return super()._post_process_scheduler()

View file

@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class StockPickingType(models.Model):
_inherit = 'stock.picking.type'
code = fields.Selection(selection_add=[
('mrp_operation', 'Manufacturing')
], ondelete={'mrp_operation': 'cascade'})
count_mo_todo = fields.Integer(string="Number of Manufacturing Orders to Process",
compute='_get_mo_count')
count_mo_waiting = fields.Integer(string="Number of Manufacturing Orders Waiting",
compute='_get_mo_count')
count_mo_late = fields.Integer(string="Number of Manufacturing Orders Late",
compute='_get_mo_count')
use_create_components_lots = fields.Boolean(
string="Create New Lots/Serial Numbers for Components",
help="Allow to create new lot/serial numbers for the components",
default=False,
)
use_auto_consume_components_lots = fields.Boolean(
string="Consume Reserved Lots/Serial Numbers automatically",
help="Allow automatic consumption of tracked components that are reserved",
)
def _get_mo_count(self):
mrp_picking_types = self.filtered(lambda picking: picking.code == 'mrp_operation')
if not mrp_picking_types:
self.count_mo_waiting = False
self.count_mo_todo = False
self.count_mo_late = False
return
domains = {
'count_mo_waiting': [('reservation_state', '=', 'waiting')],
'count_mo_todo': ['|', ('state', 'in', ('confirmed', 'draft', 'progress', 'to_close')), ('is_planned', '=', True)],
'count_mo_late': ['|', ('delay_alert_date', '!=', False), '&', ('date_deadline', '<', fields.Date.today()), ('state', '=', 'confirmed')],
}
for field in domains:
data = self.env['mrp.production']._read_group(domains[field] +
[('state', 'not in', ('done', 'cancel')), ('picking_type_id', 'in', self.ids)],
['picking_type_id'], ['picking_type_id'])
count = {x['picking_type_id'] and x['picking_type_id'][0]: x['picking_type_id_count'] for x in data}
for record in mrp_picking_types:
record[field] = count.get(record.id, 0)
remaining = (self - mrp_picking_types)
if remaining:
remaining.count_mo_waiting = False
remaining.count_mo_todo = False
remaining.count_mo_late = False
def get_mrp_stock_picking_action_picking_type(self):
action = self.env["ir.actions.actions"]._for_xml_id('mrp.mrp_production_action_picking_deshboard')
if self:
action['display_name'] = self.display_name
return action
@api.onchange('code')
def _onchange_code(self):
if self.code == 'mrp_operation':
self.use_create_lots = True
self.use_existing_lots = True
class StockPicking(models.Model):
_inherit = 'stock.picking'
has_kits = fields.Boolean(compute='_compute_has_kits')
@api.depends('move_ids')
def _compute_has_kits(self):
for picking in self:
picking.has_kits = any(picking.move_ids.mapped('bom_line_id'))
def _less_quantities_than_expected_add_documents(self, moves, documents):
documents = super(StockPicking, self)._less_quantities_than_expected_add_documents(moves, documents)
def _keys_in_groupby(move):
""" group by picking and the responsible for the product the
move.
"""
return (move.raw_material_production_id, move.product_id.responsible_id)
production_documents = self._log_activity_get_documents(moves, 'move_dest_ids', 'DOWN', _keys_in_groupby)
return {**documents, **production_documents}

View file

@ -0,0 +1,15 @@
from odoo import models, _
from odoo.exceptions import RedirectWarning
class StockQuant(models.Model):
_inherit = 'stock.quant'
def action_apply_inventory(self):
if self.sudo().product_id.filtered("is_kits"):
raise RedirectWarning(
_('You should update the components quantity instead of directly updating the quantity of the kit product.'),
self.env.ref('stock.action_view_inventory_tree').id,
_("Return to Inventory"),
)
return super().action_apply_inventory()

View file

@ -0,0 +1,235 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, SUPERUSER_ID, _
from odoo.osv import expression
from odoo.addons.stock.models.stock_rule import ProcurementException
from odoo.tools import float_compare, OrderedSet
class StockRule(models.Model):
_inherit = 'stock.rule'
action = fields.Selection(selection_add=[
('manufacture', 'Manufacture')
], ondelete={'manufacture': 'cascade'})
def _get_message_dict(self):
message_dict = super(StockRule, self)._get_message_dict()
source, destination, operation = self._get_message_values()
manufacture_message = _('When products are needed in <b>%s</b>, <br/> a manufacturing order is created to fulfill the need.') % (destination)
if self.location_src_id:
manufacture_message += _(' <br/><br/> The components will be taken from <b>%s</b>.') % (source)
message_dict.update({
'manufacture': manufacture_message
})
return message_dict
@api.depends('action')
def _compute_picking_type_code_domain(self):
remaining = self.browse()
for rule in self:
if rule.action == 'manufacture':
rule.picking_type_code_domain = 'mrp_operation'
else:
remaining |= rule
super(StockRule, remaining)._compute_picking_type_code_domain()
def _should_auto_confirm_procurement_mo(self, p):
return (not p.orderpoint_id and p.move_raw_ids) or (p.move_dest_ids.procure_method != 'make_to_order' and not p.move_raw_ids and not p.workorder_ids)
@api.model
def _run_manufacture(self, procurements):
productions_values_by_company = defaultdict(list)
for procurement, rule in procurements:
if float_compare(procurement.product_qty, 0, precision_rounding=procurement.product_uom.rounding) <= 0:
# If procurement contains negative quantity, don't create a MO that would be for a negative value.
continue
bom = rule._get_matching_bom(procurement.product_id, procurement.company_id, procurement.values)
productions_values_by_company[procurement.company_id.id].append(rule._prepare_mo_vals(*procurement, bom))
for company_id, productions_values in productions_values_by_company.items():
# create the MO as SUPERUSER because the current user may not have the rights to do it (mto product launched by a sale for example)
productions = self.env['mrp.production'].with_user(SUPERUSER_ID).sudo().with_company(company_id).create(productions_values)
productions.filtered(self._should_auto_confirm_procurement_mo).action_confirm()
for production in productions:
origin_production = production.move_dest_ids and production.move_dest_ids[0].raw_material_production_id or False
orderpoint = production.orderpoint_id
if orderpoint and orderpoint.create_uid.id == SUPERUSER_ID and orderpoint.trigger == 'manual':
production.message_post(
body=_('This production order has been created from Replenishment Report.'),
message_type='comment',
subtype_xmlid='mail.mt_note')
elif orderpoint:
production.message_post_with_view(
'mail.message_origin_link',
values={'self': production, 'origin': orderpoint},
subtype_id=self.env.ref('mail.mt_note').id)
elif origin_production:
production.message_post_with_view(
'mail.message_origin_link',
values={'self': production, 'origin': origin_production},
subtype_id=self.env.ref('mail.mt_note').id)
return True
@api.model
def _run_pull(self, procurements):
# Override to correctly assign the move generated from the pull
# in its production order (pbm_sam only)
for procurement, rule in procurements:
warehouse_id = rule.warehouse_id
if not warehouse_id:
warehouse_id = rule.location_dest_id.warehouse_id
manu_rule = rule.route_id.rule_ids.filtered(lambda r: r.action == 'manufacture' and r.warehouse_id == warehouse_id)
if warehouse_id.manufacture_steps != 'pbm_sam' or not manu_rule:
continue
if rule.picking_type_id == warehouse_id.sam_type_id or (
warehouse_id.sam_loc_id and warehouse_id.sam_loc_id.parent_path in rule.location_src_id.parent_path
):
if float_compare(procurement.product_qty, 0, precision_rounding=procurement.product_uom.rounding) < 0:
procurement.values['group_id'] = procurement.values['group_id'].stock_move_ids.filtered(
lambda m: m.state not in ['done', 'cancel']).move_orig_ids.group_id[:1]
continue
manu_type_id = manu_rule[0].picking_type_id
if manu_type_id:
name = manu_type_id.sequence_id.next_by_id()
else:
name = self.env['ir.sequence'].next_by_code('mrp.production') or _('New')
# Create now the procurement group that will be assigned to the new MO
# This ensure that the outgoing move PostProduction -> Stock is linked to its MO
# rather than the original record (MO or SO)
group = procurement.values.get('group_id')
if group:
procurement.values['group_id'] = group.copy({'name': name})
else:
procurement.values['group_id'] = self.env["procurement.group"].create({'name': name})
return super()._run_pull(procurements)
def _get_custom_move_fields(self):
fields = super(StockRule, self)._get_custom_move_fields()
fields += ['bom_line_id']
return fields
def _get_matching_bom(self, product_id, company_id, values):
if values.get('bom_id', False):
return values['bom_id']
if values.get('orderpoint_id', False) and values['orderpoint_id'].bom_id:
return values['orderpoint_id'].bom_id
return self.env['mrp.bom']._bom_find(product_id, picking_type=self.picking_type_id, bom_type='normal', company_id=company_id.id)[product_id]
def _prepare_mo_vals(self, product_id, product_qty, product_uom, location_dest_id, name, origin, company_id, values, bom):
date_planned = self._get_date_planned(product_id, company_id, values)
date_deadline = values.get('date_deadline') or date_planned + relativedelta(days=product_id.produce_delay)
mo_values = {
'origin': origin,
'product_id': product_id.id,
'product_description_variants': values.get('product_description_variants'),
'product_qty': product_uom._compute_quantity(product_qty, bom.product_uom_id) if bom else product_qty,
'product_uom_id': bom.product_uom_id.id if bom else product_uom.id,
'location_src_id': self.location_src_id.id or self.picking_type_id.default_location_src_id.id or location_dest_id.id,
'location_dest_id': location_dest_id.id,
'bom_id': bom.id,
'date_deadline': date_deadline,
'date_planned_start': date_planned,
'date_planned_finished': fields.Datetime.from_string(values['date_planned']),
'procurement_group_id': False,
'propagate_cancel': self.propagate_cancel,
'orderpoint_id': values.get('orderpoint_id', False) and values.get('orderpoint_id').id,
'picking_type_id': self.picking_type_id.id or values['warehouse_id'].manu_type_id.id,
'company_id': company_id.id,
'move_dest_ids': values.get('move_dest_ids') and [(4, x.id) for x in values['move_dest_ids']] or False,
'user_id': False,
}
# Use the procurement group created in _run_pull mrp override
# Preserve the origin from the original stock move, if available
if location_dest_id.warehouse_id.manufacture_steps == 'pbm_sam' and values.get('move_dest_ids') and values.get('group_id') and values['move_dest_ids'][0].origin != values['group_id'].name:
origin = values['move_dest_ids'][0].origin
mo_values.update({
'name': values['group_id'].name,
'procurement_group_id': values['group_id'].id,
'origin': origin,
})
return mo_values
def _get_date_planned(self, product_id, company_id, values):
format_date_planned = fields.Datetime.from_string(values['date_planned'])
date_planned = format_date_planned - relativedelta(days=product_id.produce_delay)
if date_planned == format_date_planned:
date_planned = date_planned - relativedelta(hours=1)
return date_planned
def _get_lead_days(self, product, **values):
"""Add the product and company manufacture delay to the cumulative delay
and cumulative description.
"""
delay, delay_description = super()._get_lead_days(product, **values)
bypass_delay_description = self.env.context.get('bypass_delay_description')
manufacture_rule = self.filtered(lambda r: r.action == 'manufacture')
if not manufacture_rule:
return delay, delay_description
manufacture_rule.ensure_one()
manufacture_delay = product.produce_delay
delay += manufacture_delay
if not bypass_delay_description:
delay_description.append((_('Manufacturing Lead Time'), _('+ %d day(s)', manufacture_delay)))
security_delay = manufacture_rule.picking_type_id.company_id.manufacturing_lead
delay += security_delay
if not bypass_delay_description:
delay_description.append((_('Manufacture Security Lead Time'), _('+ %d day(s)', security_delay)))
days_to_order = values.get('days_to_order', product.product_tmpl_id.days_to_prepare_mo)
if not bypass_delay_description:
delay_description.append((_('Days to Supply Components'), _('+ %d day(s)', days_to_order)))
return delay + days_to_order, delay_description
def _push_prepare_move_copy_values(self, move_to_copy, new_date):
new_move_vals = super(StockRule, self)._push_prepare_move_copy_values(move_to_copy, new_date)
new_move_vals['production_id'] = False
return new_move_vals
class ProcurementGroup(models.Model):
_inherit = 'procurement.group'
mrp_production_ids = fields.One2many('mrp.production', 'procurement_group_id')
@api.model
def run(self, procurements, raise_user_error=True):
""" If 'run' is called on a kit, this override is made in order to call
the original 'run' method with the values of the components of that kit.
"""
procurements_without_kit = []
product_by_company = defaultdict(OrderedSet)
for procurement in procurements:
product_by_company[procurement.company_id].add(procurement.product_id.id)
kits_by_company = {
company: self.env['mrp.bom']._bom_find(self.env['product.product'].browse(product_ids), company_id=company.id, bom_type='phantom')
for company, product_ids in product_by_company.items()
}
for procurement in procurements:
bom_kit = kits_by_company[procurement.company_id].get(procurement.product_id)
if bom_kit:
order_qty = procurement.product_uom._compute_quantity(procurement.product_qty, bom_kit.product_uom_id, round=False)
qty_to_produce = (order_qty / bom_kit.product_qty)
boms, bom_sub_lines = bom_kit.explode(procurement.product_id, qty_to_produce)
for bom_line, bom_line_data in bom_sub_lines:
bom_line_uom = bom_line.product_uom_id
quant_uom = bom_line.product_id.uom_id
# recreate dict of values since each child has its own bom_line_id
values = dict(procurement.values, bom_line_id=bom_line.id)
component_qty, procurement_uom = bom_line_uom._adjust_uom_quantities(bom_line_data['qty'], quant_uom)
procurements_without_kit.append(self.env['procurement.group'].Procurement(
bom_line.product_id, component_qty, procurement_uom,
procurement.location_id, procurement.name,
procurement.origin, procurement.company_id, values))
else:
procurements_without_kit.append(procurement)
return super(ProcurementGroup, self).run(procurements_without_kit, raise_user_error=raise_user_error)
def _get_moves_to_assign_domain(self, company_id):
domain = super(ProcurementGroup, self)._get_moves_to_assign_domain(company_id)
domain = expression.AND([domain, [('production_id', '=', False)]])
return domain

View file

@ -0,0 +1,52 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import _, api, fields, models
class StockScrap(models.Model):
_inherit = 'stock.scrap'
production_id = fields.Many2one(
'mrp.production', 'Manufacturing Order',
states={'done': [('readonly', True)]}, check_company=True)
workorder_id = fields.Many2one(
'mrp.workorder', 'Work Order',
states={'done': [('readonly', True)]},
check_company=True) # Not to restrict or prefer quants, but informative
@api.onchange('workorder_id')
def _onchange_workorder_id(self):
if self.workorder_id:
self.location_id = self.workorder_id.production_id.location_src_id.id
@api.onchange('production_id')
def _onchange_production_id(self):
if self.production_id:
self.location_id = self.production_id.move_raw_ids.filtered(lambda x: x.state not in ('done', 'cancel')) and self.production_id.location_src_id.id or self.production_id.location_dest_id.id
def _prepare_move_values(self):
vals = super(StockScrap, self)._prepare_move_values()
if self.production_id:
vals['origin'] = vals['origin'] or self.production_id.name
if self.product_id in self.production_id.move_finished_ids.mapped('product_id'):
vals.update({'production_id': self.production_id.id})
else:
vals.update({'raw_material_production_id': self.production_id.id})
return vals
@api.onchange('lot_id')
def _onchange_serial_number(self):
if self.product_id.tracking == 'serial' and self.lot_id:
if self.production_id:
message, recommended_location = self.env['stock.quant']._check_serial_number(self.product_id,
self.lot_id,
self.company_id,
self.location_id,
self.production_id.location_dest_id)
if message:
if recommended_location:
self.location_id = recommended_location
return {'warning': {'title': _('Warning'), 'message': message}}
else:
return super()._onchange_serial_number()

View file

@ -0,0 +1,34 @@
from odoo import models, api
class MrpStockReport(models.TransientModel):
_inherit = 'stock.traceability.report'
@api.model
def _get_reference(self, move_line):
res_model, res_id, ref = super(MrpStockReport, self)._get_reference(move_line)
if move_line.move_id.production_id and not move_line.move_id.scrapped:
res_model = 'mrp.production'
res_id = move_line.move_id.production_id.id
ref = move_line.move_id.production_id.name
if move_line.move_id.raw_material_production_id and not move_line.move_id.scrapped:
res_model = 'mrp.production'
res_id = move_line.move_id.raw_material_production_id.id
ref = move_line.move_id.raw_material_production_id.name
if move_line.move_id.unbuild_id:
res_model = 'mrp.unbuild'
res_id = move_line.move_id.unbuild_id.id
ref = move_line.move_id.unbuild_id.name
if move_line.move_id.consume_unbuild_id:
res_model = 'mrp.unbuild'
res_id = move_line.move_id.consume_unbuild_id.id
ref = move_line.move_id.consume_unbuild_id.name
return res_model, res_id, ref
@api.model
def _get_linked_move_lines(self, move_line):
move_lines, is_used = super(MrpStockReport, self)._get_linked_move_lines(move_line)
if not move_lines:
move_lines = (move_line.move_id.consume_unbuild_id and move_line.produce_line_ids) or (move_line.move_id.production_id and move_line.consume_line_ids)
if not is_used:
is_used = (move_line.move_id.unbuild_id and move_line.consume_line_ids) or (move_line.move_id.raw_material_production_id and move_line.produce_line_ids)
return move_lines, is_used

View file

@ -0,0 +1,324 @@
# -*- 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 ValidationError, UserError
from odoo.tools import split_every
class StockWarehouse(models.Model):
_inherit = 'stock.warehouse'
manufacture_to_resupply = fields.Boolean(
'Manufacture to Resupply', default=True,
help="When products are manufactured, they can be manufactured in this warehouse.")
manufacture_pull_id = fields.Many2one(
'stock.rule', 'Manufacture Rule')
manufacture_mto_pull_id = fields.Many2one(
'stock.rule', 'Manufacture MTO Rule')
pbm_mto_pull_id = fields.Many2one(
'stock.rule', 'Picking Before Manufacturing MTO Rule')
sam_rule_id = fields.Many2one(
'stock.rule', 'Stock After Manufacturing Rule')
manu_type_id = fields.Many2one(
'stock.picking.type', 'Manufacturing Operation Type',
domain="[('code', '=', 'mrp_operation'), ('company_id', '=', company_id)]", check_company=True)
pbm_type_id = fields.Many2one('stock.picking.type', 'Picking Before Manufacturing Operation Type', check_company=True)
sam_type_id = fields.Many2one('stock.picking.type', 'Stock After Manufacturing Operation Type', check_company=True)
manufacture_steps = fields.Selection([
('mrp_one_step', 'Manufacture (1 step)'),
('pbm', 'Pick components and then manufacture (2 steps)'),
('pbm_sam', 'Pick components, manufacture and then store products (3 steps)')],
'Manufacture', default='mrp_one_step', required=True,
help="Produce : Move the components to the production location\
directly and start the manufacturing process.\nPick / Produce : Unload\
the components from the Stock to Input location first, and then\
transfer it to the Production location.")
pbm_route_id = fields.Many2one('stock.route', 'Picking Before Manufacturing Route', ondelete='restrict')
pbm_loc_id = fields.Many2one('stock.location', 'Picking before Manufacturing Location', check_company=True)
sam_loc_id = fields.Many2one('stock.location', 'Stock after Manufacturing Location', check_company=True)
def get_rules_dict(self):
result = super(StockWarehouse, self).get_rules_dict()
production_location_id = self._get_production_location()
for warehouse in self:
result[warehouse.id].update({
'mrp_one_step': [],
'pbm': [
self.Routing(warehouse.lot_stock_id, warehouse.pbm_loc_id, warehouse.pbm_type_id, 'pull'),
self.Routing(warehouse.pbm_loc_id, production_location_id, warehouse.manu_type_id, 'pull'),
],
'pbm_sam': [
self.Routing(warehouse.lot_stock_id, warehouse.pbm_loc_id, warehouse.pbm_type_id, 'pull'),
self.Routing(warehouse.pbm_loc_id, production_location_id, warehouse.manu_type_id, 'pull'),
self.Routing(warehouse.sam_loc_id, warehouse.lot_stock_id, warehouse.sam_type_id, 'push'),
],
})
result[warehouse.id].update(warehouse._get_receive_rules_dict())
return result
@api.model
def _get_production_location(self):
location = self.env['stock.location'].search([('usage', '=', 'production'), ('company_id', '=', self.company_id.id)], limit=1)
if not location:
raise UserError(_('Can\'t find any production location.'))
return location
def _get_routes_values(self):
routes = super(StockWarehouse, self)._get_routes_values()
routes.update({
'pbm_route_id': {
'routing_key': self.manufacture_steps,
'depends': ['manufacture_steps', 'manufacture_to_resupply'],
'route_update_values': {
'name': self._format_routename(route_type=self.manufacture_steps),
'active': self.manufacture_steps != 'mrp_one_step',
},
'route_create_values': {
'product_categ_selectable': True,
'warehouse_selectable': True,
'product_selectable': False,
'company_id': self.company_id.id,
'sequence': 10,
},
'rules_values': {
'active': True,
}
}
})
routes.update(self._get_receive_routes_values('manufacture_to_resupply'))
return routes
def _get_route_name(self, route_type):
names = {
'mrp_one_step': _('Manufacture (1 step)'),
'pbm': _('Pick components and then manufacture'),
'pbm_sam': _('Pick components, manufacture and then store products (3 steps)'),
}
if route_type in names:
return names[route_type]
else:
return super(StockWarehouse, self)._get_route_name(route_type)
def _get_global_route_rules_values(self):
rules = super(StockWarehouse, self)._get_global_route_rules_values()
location_src = self.manufacture_steps == 'mrp_one_step' and self.lot_stock_id or self.pbm_loc_id
production_location = self._get_production_location()
location_dest_id = self.manufacture_steps == 'pbm_sam' and self.sam_loc_id or self.lot_stock_id
rules.update({
'manufacture_pull_id': {
'depends': ['manufacture_steps', 'manufacture_to_resupply'],
'create_values': {
'action': 'manufacture',
'procure_method': 'make_to_order',
'company_id': self.company_id.id,
'picking_type_id': self.manu_type_id.id,
'route_id': self._find_or_create_global_route('mrp.route_warehouse0_manufacture', _('Manufacture')).id
},
'update_values': {
'active': self.manufacture_to_resupply,
'name': self._format_rulename(location_dest_id, False, 'Production'),
'location_dest_id': location_dest_id.id,
'propagate_cancel': self.manufacture_steps == 'pbm_sam'
},
},
'manufacture_mto_pull_id': {
'depends': ['manufacture_steps', 'manufacture_to_resupply'],
'create_values': {
'procure_method': 'mts_else_mto',
'company_id': self.company_id.id,
'action': 'pull',
'auto': 'manual',
'route_id': self._find_or_create_global_route('stock.route_warehouse0_mto', _('Make To Order')).id,
'location_dest_id': production_location.id,
'location_src_id': location_src.id,
'picking_type_id': self.manu_type_id.id
},
'update_values': {
'name': self._format_rulename(location_src, production_location, 'MTO'),
'active': self.manufacture_to_resupply,
},
},
'pbm_mto_pull_id': {
'depends': ['manufacture_steps', 'manufacture_to_resupply'],
'create_values': {
'procure_method': 'make_to_order',
'company_id': self.company_id.id,
'action': 'pull',
'auto': 'manual',
'route_id': self._find_or_create_global_route('stock.route_warehouse0_mto', _('Make To Order')).id,
'name': self._format_rulename(self.lot_stock_id, self.pbm_loc_id, 'MTO'),
'location_dest_id': self.pbm_loc_id.id,
'location_src_id': self.lot_stock_id.id,
'picking_type_id': self.pbm_type_id.id
},
'update_values': {
'active': self.manufacture_steps != 'mrp_one_step' and self.manufacture_to_resupply,
}
},
# The purpose to move sam rule in the manufacture route instead of
# pbm_route_id is to avoid conflict with receipt in multiple
# step. For example if the product is manufacture and receipt in two
# step it would conflict in WH/Stock since product could come from
# WH/post-prod or WH/input. We do not have this conflict with
# manufacture route since it is set on the product.
'sam_rule_id': {
'depends': ['manufacture_steps', 'manufacture_to_resupply'],
'create_values': {
'procure_method': 'make_to_order',
'company_id': self.company_id.id,
'action': 'pull',
'auto': 'manual',
'route_id': self._find_or_create_global_route('mrp.route_warehouse0_manufacture', _('Manufacture')).id,
'name': self._format_rulename(self.sam_loc_id, self.lot_stock_id, False),
'location_dest_id': self.lot_stock_id.id,
'location_src_id': self.sam_loc_id.id,
'picking_type_id': self.sam_type_id.id
},
'update_values': {
'active': self.manufacture_steps == 'pbm_sam' and self.manufacture_to_resupply,
}
}
})
return rules
def _get_locations_values(self, vals, code=False):
values = super(StockWarehouse, self)._get_locations_values(vals, code=code)
def_values = self.default_get(['company_id', 'manufacture_steps'])
manufacture_steps = vals.get('manufacture_steps', def_values['manufacture_steps'])
code = vals.get('code') or code or ''
code = code.replace(' ', '').upper()
company_id = vals.get('company_id', def_values['company_id'])
values.update({
'pbm_loc_id': {
'name': _('Pre-Production'),
'active': manufacture_steps in ('pbm', 'pbm_sam'),
'usage': 'internal',
'barcode': self._valid_barcode(code + '-PREPRODUCTION', company_id)
},
'sam_loc_id': {
'name': _('Post-Production'),
'active': manufacture_steps == 'pbm_sam',
'usage': 'internal',
'barcode': self._valid_barcode(code + '-POSTPRODUCTION', company_id)
},
})
return values
def _get_sequence_values(self, name=False, code=False):
values = super(StockWarehouse, self)._get_sequence_values(name=name, code=code)
values.update({
'pbm_type_id': {'name': self.name + ' ' + _('Sequence picking before manufacturing'), 'prefix': self.code + '/' + (self.pbm_type_id.sequence_code or 'PC') + '/', 'padding': 5, 'company_id': self.company_id.id},
'sam_type_id': {'name': self.name + ' ' + _('Sequence stock after manufacturing'), 'prefix': self.code + '/' + (self.sam_type_id.sequence_code or 'SFP') + '/', 'padding': 5, 'company_id': self.company_id.id},
'manu_type_id': {'name': self.name + ' ' + _('Sequence production'), 'prefix': self.code + '/' + (self.manu_type_id.sequence_code or 'MO') + '/', 'padding': 5, 'company_id': self.company_id.id},
})
return values
def _get_picking_type_create_values(self, max_sequence):
data, next_sequence = super(StockWarehouse, self)._get_picking_type_create_values(max_sequence)
data.update({
'pbm_type_id': {
'name': _('Pick Components'),
'code': 'internal',
'use_create_lots': True,
'use_existing_lots': True,
'default_location_src_id': self.lot_stock_id.id,
'default_location_dest_id': self.pbm_loc_id.id,
'sequence': next_sequence + 1,
'sequence_code': 'PC',
'company_id': self.company_id.id,
},
'sam_type_id': {
'name': _('Store Finished Product'),
'code': 'internal',
'use_create_lots': True,
'use_existing_lots': True,
'default_location_src_id': self.sam_loc_id.id,
'default_location_dest_id': self.lot_stock_id.id,
'sequence': next_sequence + 3,
'sequence_code': 'SFP',
'company_id': self.company_id.id,
},
'manu_type_id': {
'name': _('Manufacturing'),
'code': 'mrp_operation',
'use_create_lots': True,
'use_existing_lots': True,
'sequence': next_sequence + 2,
'sequence_code': 'MO',
'company_id': self.company_id.id,
},
})
return data, max_sequence + 4
def _get_picking_type_update_values(self):
data = super(StockWarehouse, self)._get_picking_type_update_values()
data.update({
'pbm_type_id': {
'active': self.manufacture_to_resupply and self.manufacture_steps in ('pbm', 'pbm_sam') and self.active,
'barcode': self.code.replace(" ", "").upper() + "-PC",
},
'sam_type_id': {
'active': self.manufacture_to_resupply and self.manufacture_steps == 'pbm_sam' and self.active,
'barcode': self.code.replace(" ", "").upper() + "-SFP",
},
'manu_type_id': {
'active': self.manufacture_to_resupply and self.active,
'default_location_src_id': self.manufacture_steps in ('pbm', 'pbm_sam') and self.pbm_loc_id.id or self.lot_stock_id.id,
'default_location_dest_id': self.manufacture_steps == 'pbm_sam' and self.sam_loc_id.id or self.lot_stock_id.id,
},
})
return data
def _create_missing_locations(self, vals):
super()._create_missing_locations(vals)
for company_id in self.company_id:
location = self.env['stock.location'].search([('usage', '=', 'production'), ('company_id', '=', company_id.id)], limit=1)
if not location:
company_id._create_production_location()
def write(self, vals):
if any(field in vals for field in ('manufacture_steps', 'manufacture_to_resupply')):
for warehouse in self:
warehouse._update_location_manufacture(vals.get('manufacture_steps', warehouse.manufacture_steps))
return super(StockWarehouse, self).write(vals)
def _get_all_routes(self):
routes = super(StockWarehouse, self)._get_all_routes()
routes |= self.filtered(lambda self: self.manufacture_to_resupply and self.manufacture_pull_id and self.manufacture_pull_id.route_id).mapped('manufacture_pull_id').mapped('route_id')
return routes
def _update_location_manufacture(self, new_manufacture_step):
self.mapped('pbm_loc_id').write({'active': new_manufacture_step != 'mrp_one_step'})
self.mapped('sam_loc_id').write({'active': new_manufacture_step == 'pbm_sam'})
def _update_name_and_code(self, name=False, code=False):
res = super(StockWarehouse, self)._update_name_and_code(name, code)
# change the manufacture stock rule name
for warehouse in self:
if warehouse.manufacture_pull_id and name:
warehouse.manufacture_pull_id.write({'name': warehouse.manufacture_pull_id.name.replace(warehouse.name, name, 1)})
return res
class Orderpoint(models.Model):
_inherit = "stock.warehouse.orderpoint"
@api.constrains('product_id')
def check_product_is_not_kit(self):
if self.env['mrp.bom'].search(['|', ('product_id', 'in', self.product_id.ids),
'&', ('product_id', '=', False), ('product_tmpl_id', 'in', self.product_id.product_tmpl_id.ids),
('type', '=', 'phantom')], count=True):
raise ValidationError(_("A product with a kit-type bill of materials can not have a reordering rule."))
def _get_orderpoint_products(self):
non_kit_ids = []
for products in split_every(2000, super()._get_orderpoint_products().ids, self.env['product.product'].browse):
kit_ids = set(k.id for k in self.env['mrp.bom']._bom_find(products, bom_type='phantom').keys())
non_kit_ids.extend(id_ for id_ in products.ids if id_ not in kit_ids)
products.invalidate_recordset()
return self.env['product.product'].browse(non_kit_ids)