19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:47 +01:00
parent accf5918df
commit 6e65e8c877
688 changed files with 225434 additions and 199401 deletions

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import mrp_document
from . import product_document
from . import res_config_settings
from . import mrp_bom
from . import mrp_routing
@ -13,10 +13,13 @@ from . import mrp_workorder
from . import product
from . import res_company
from . import stock_move
from . import stock_move_line
from . import stock_orderpoint
from . import stock_picking
from . import stock_lot
from . import stock_reference
from . import stock_rule
from . import stock_scrap
from . import stock_warehouse
from . import stock_quant
from . import stock_replenish_mixin

View file

@ -0,0 +1,18 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class IrAttachment(models.Model):
_inherit = "ir.attachment"
def _post_add_create(self, **kwargs):
super()._post_add_create(**kwargs)
if self.res_model == "mrp.bom":
bom = self.env['mrp.bom'].browse(self.res_id)
self.res_model = bom.product_id._name if bom.product_id else bom.product_tmpl_id._name
self.res_id = bom.product_id.id if bom.product_id else bom.product_tmpl_id.id
self.env['product.document'].create({
'ir_attachment_id': self.id,
'attached_on_mrp': 'bom'
})

View file

@ -1,10 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _, Command
from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
from odoo.osv.expression import AND, OR
from odoo.tools import float_round
from odoo.fields import Command, Domain
from odoo.tools import float_compare
from odoo.tools.misc import clean_context, OrderedSet
from collections import defaultdict
@ -13,7 +13,7 @@ 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']
_inherit = ['mail.thread', 'product.catalog.mixin']
_rec_name = 'product_tmpl_id'
_rec_names_search = ['product_tmpl_id', 'code']
_order = "sequence, id"
@ -31,34 +31,38 @@ class MrpBom(models.Model):
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)
domain="[('type', '=', 'consu')]", 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)]",
domain="['&', ('product_tmpl_id', '=', product_tmpl_id), ('type', '=', 'consu')]",
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,
digits='Product Unit', 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',
'uom.uom', 'Unit',
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')
help="Unit of Measure (Unit of Measure) is the unit of measurement for the inventory control")
sequence = fields.Integer('Sequence')
operation_ids = fields.One2many('mrp.routing.workcenter', 'bom_id', 'Operations', copy=True)
operation_count = fields.Integer('Operations Count', compute='_compute_operation_count')
show_copy_operations_button = fields.Boolean(
compute="_compute_show_copy_operations_button",
help="Technical field used to control the visibility of the 'Copy Existing Operations' button.")
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)]",
'stock.picking.type', 'Operation Type', domain="[('code', '=', 'mrp_operation')]",
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 "
"a Manufacturing Order for that product using a BoM of the same operation type.If not,"
"the operation type is not taken into account in the BoM search. That allows "
"to define stock rules which trigger different manufacturing orders with different BoMs.")
company_id = fields.Many2one(
'res.company', 'Company', index=True,
@ -70,7 +74,7 @@ class MrpBom(models.Model):
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"
" Note that in the case of component Highlight 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',
@ -82,10 +86,20 @@ class MrpBom(models.Model):
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."
)
produce_delay = fields.Integer(
'Manufacturing Lead Time', default=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.")
days_to_prepare_mo = fields.Integer(
string="Days to prepare Manufacturing Order", default=0,
help="Create and confirm Manufacturing Orders this many days in advance, to have enough time to replenish components or manufacture semi-finished products.")
show_set_bom_button = fields.Boolean(compute="_compute_show_set_bom_button")
batch_size = fields.Float('Batch Size', default=1.0, digits='Product Unit', help="All automatically generated manufacturing orders for this product will be of this size.")
enable_batch_size = fields.Boolean(default=False)
_sql_constraints = [
('qty_positive', 'check (product_qty > 0)', 'The quantity to produce must be positive!'),
]
_qty_positive = models.Constraint(
'check (product_qty > 0)',
'The quantity to produce must be positive!',
)
@api.depends(
'product_tmpl_id.attribute_line_ids.value_ids',
@ -94,14 +108,26 @@ class MrpBom(models.Model):
)
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()
bom.possible_product_template_attribute_value_ids = bom.product_tmpl_id.valid_product_template_attribute_line_ids.product_template_value_ids._only_active()
@api.onchange('product_id')
def _onchange_product_id(self):
if self.product_id:
warning = (
self.bom_line_ids.bom_product_template_attribute_value_ids or
self.operation_ids.bom_product_template_attribute_value_ids or
self.byproduct_ids.bom_product_template_attribute_value_ids
) and {
'warning': {
'title': _("Warning"),
'message': _("Changing the product or variant will permanently reset all previously encoded variant-related data."),
}
}
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
if warning:
return warning
@api.constrains('active', 'product_id', 'product_tmpl_id', 'bom_line_ids')
def _check_bom_cycle(self):
@ -118,8 +144,9 @@ class MrpBom(models.Model):
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))
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
@ -134,11 +161,11 @@ class MrpBom(models.Model):
_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)
if self.bom_line_ids.product_id:
boms_to_check |= self.search(Domain.OR(
self._bom_find_domain(product)
for product in self.bom_line_ids.product_id
))
for bom in boms_to_check:
if not bom.active:
@ -154,12 +181,6 @@ class MrpBom(models.Model):
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:
@ -180,15 +201,17 @@ class MrpBom(models.Model):
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)
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."))
for product in bom.product_tmpl_id.product_variant_ids:
total_variant_cost_share = sum(bom.byproduct_ids.filtered(lambda bp: not bp._skip_byproduct_line(product) and not bp.product_uom_id.is_zero(bp.product_qty)).mapped('cost_share'))
if float_compare(total_variant_cost_share, 100, precision_digits=2) > 0:
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):
if self.type == 'phantom' and self._origin and self.env['stock.move'].search_count([('bom_line_id', 'in', self._origin.bom_line_ids.ids)], limit=1):
return {
'warning': {
'title': _('Warning'),
@ -198,20 +221,23 @@ class MrpBom(models.Model):
}
}
@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
warning = (
self.bom_line_ids.bom_product_template_attribute_value_ids or
self.operation_ids.bom_product_template_attribute_value_ids or
self.byproduct_ids.bom_product_template_attribute_value_ids
) and {
'warning': {
'title': _("Warning"),
'message': _("Changing the product or variant will permanently reset all previously encoded variant-related data."),
}
}
default_uom_id = self.env.context.get('default_product_uom_id')
# Avoids updating the BoM's UoM in case a specific UoM was passed through as a default value.
if self.product_uom_id.id != default_uom_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
@ -223,67 +249,136 @@ class MrpBom(models.Model):
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
self.code = _("%(product_name)s (new) %(number_of_boms)s", product_name=self.product_tmpl_id.name, number_of_boms=number_of_bom_of_this_product)
if warning:
return warning
@api.model_create_multi
def create(self, vals_list):
res = super().create(vals_list)
# Checks if the BoM was created from a Manufacturing Order (through Generate BoM action).
parent_production_id = self.env.context.get('parent_production_id')
if parent_production_id: # In this case, assign the newly created BoM to the MO.
# Clean context to avoid parasitic default values.
env = self.env(context=clean_context(self.env.context))
production = env['mrp.production'].browse(parent_production_id)
production._link_bom(res[0])
return res
def write(self, vals):
res = super().write(vals)
relevant_fields = ['bom_line_ids', 'byproduct_ids', 'product_tmpl_id', 'product_id', 'product_qty']
if any(field_name in vals for field_name in relevant_fields):
self._set_outdated_bom_in_productions()
if 'sequence' in vals and self and self[-1].id == list(self._prefetch_ids)[-1]:
self.browse(self._prefetch_ids)._check_bom_cycle()
return res
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
new_boms = super().copy(default)
for old_bom, new_bom in zip(self, new_boms):
if old_bom.operation_ids:
operations_mapping = {}
for original, copied in zip(old_bom.operation_ids, new_bom.operation_ids.sorted()):
operations_mapping[original] = copied
for bom_line in new_bom.bom_line_ids:
if bom_line.operation_id:
bom_line.operation_id = operations_mapping[bom_line.operation_id]
for byproduct in new_bom.byproduct_ids:
if byproduct.operation_id:
byproduct.operation_id = operations_mapping[byproduct.operation_id]
for operation in old_bom.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 new_boms
@api.model
def name_create(self, name):
# prevent to use string as product_tmpl_id
if isinstance(name, str):
key = 'default_' + self._rec_name
if key in self.env.context:
result = super().name_create(self.env.context[key])
self.browse(result[0]).code = name
return result
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 action_archive(self):
self.with_context(active_test=False).operation_ids.action_archive()
return super().action_archive()
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]
def action_unarchive(self):
self.with_context(active_test=False).operation_ids.action_unarchive()
return super().action_unarchive()
@api.depends('code')
def _compute_display_name(self):
for bom in self:
display_name = f"{bom.code + ': ' if bom.code else ''}{bom.product_tmpl_id.display_name}"
if self.env.context.get('display_bom_uom_qty') and (bom.product_qty > 1 or bom.product_uom_id != bom.product_tmpl_id.uom_id):
display_name += f" ({bom.product_qty} {bom.product_uom_id.name})"
bom.display_name = _('%(display_name)s', display_name=display_name)
@api.depends('operation_ids')
def _compute_operation_count(self):
for bom in self:
bom.operation_count = len(bom.operation_ids)
def _compute_show_copy_operations_button(self):
exist_operation = bool(self.env['mrp.routing.workcenter'].search_count([], limit=1))
self.show_copy_operations_button = exist_operation
def action_compute_bom_days(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 bom in self:
bom_data = self.env['report.mrp.report_bom_structure'].with_context(minimized=True)._get_bom_data(bom, warehouse, bom.product_id, ignore_stock=True)
bom.days_to_prepare_mo = self.env['report.mrp.report_bom_structure']._get_max_component_delay(bom_data['components'])
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,
}
}
@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):
if self.env['stock.warehouse.orderpoint'].search_count([('product_id', 'in', product_ids)], limit=1):
raise ValidationError(_("You can not create a kit-type bill of materials for products that have at least one reordering rule."))
@api.constrains('enable_batch_size', 'batch_size')
def _check_valid_batch_size(self):
if any(bom.enable_batch_size and bom.product_uom_id.compare(bom.batch_size, 0.0) <= 0 for bom in self):
raise ValidationError(self.env._("The batch size must be positive!"))
@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):
if self.env['mrp.production'].search_count([('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)]
domain = (
Domain('product_id', 'in', products.ids) | (
Domain('product_id', '=', False) & Domain('product_tmpl_id', 'in', products.product_tmpl_id.ids)
)
) & Domain('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'))]])
domain &= Domain('company_id', 'in', [False, 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)]])
domain &= Domain('picking_type_id', 'in', [picking_type.id, False])
if bom_type:
domain = AND([domain, [('type', '=', bom_type)]])
domain &= Domain('type', '=', bom_type)
return domain
@api.model
@ -318,29 +413,12 @@ class MrpBom(models.Model):
return bom_by_product
def explode(self, product, quantity, picking_type=False):
def explode(self, product, quantity, picking_type=False, never_attribute_values=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():
@ -351,15 +429,12 @@ class MrpBom(models.Model):
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})]
boms_done = [(self, self.env['mrp.bom.line']._prepare_bom_done_values(quantity, product, quantity, []))]
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()
@ -368,34 +443,36 @@ class MrpBom(models.Model):
current_line, current_product, current_qty, parent_line = bom_lines[0]
bom_lines = bom_lines[1:]
if current_line._skip_bom_line(current_product):
if current_line._skip_bom_line(current_product, never_attribute_values):
continue
line_quantity = current_qty * current_line.product_qty
if not current_line.product_id in product_boms:
if current_line.product_id not 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]
converted_line_quantity = current_line.product_uom_id._compute_quantity(
line_quantity / bom.product_qty, bom.product_uom_id, round=False
)
bom_lines = [(line, current_line.product_id, converted_line_quantity, current_line) for line in bom.bom_line_ids] + bom_lines
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:
if bom_line.product_id not in product_boms:
product_ids.add(bom_line.product_id.id)
boms_done.append((bom, {'qty': converted_line_quantity, 'product': current_product, 'original_qty': quantity, 'parent_line': current_line}))
boms_done.append((bom, current_line._prepare_bom_done_values(converted_line_quantity, current_product, quantity, boms_done)))
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}))
line_quantity = current_line.product_uom_id.round(line_quantity, rounding_method='UP')
lines_done.append((current_line, current_line._prepare_line_done_values(line_quantity, current_product, quantity, parent_line, boms_done)))
lines_done = self._round_last_line_done(lines_done)
return boms_done, lines_done
@api.model
def _round_last_line_done(self, lines_done):
return lines_done
@api.model
def get_import_templates(self):
return [{
@ -403,6 +480,189 @@ class MrpBom(models.Model):
'template': '/mrp/static/xls/mrp_bom.xls'
}]
def _set_outdated_bom_in_productions(self):
if not self:
return
# Searches for MOs using these BoMs to notify them that their BoM has been updated.
list_of_domain_by_bom = []
for bom in self:
if bom.product_id:
domain_by_products = Domain('product_id', '=', bom.product_id.id)
else:
domain_by_products = Domain('product_id', 'in', bom.product_tmpl_id.product_variant_ids.ids)
domain_for_confirmed_mo = Domain('state', '=', 'confirmed') & domain_by_products
# Avoid confirmed MOs if the BoM's product was changed.
domain_by_states = Domain('state', '=', 'draft') | domain_for_confirmed_mo
list_of_domain_by_bom.append(Domain('bom_id', '=', bom.id) & domain_by_states)
productions = self.env['mrp.production'].search(Domain.OR(list_of_domain_by_bom))
if productions:
productions.is_outdated_bom = True
# -------------------------------------------------------------------------
# CATALOG
# -------------------------------------------------------------------------
def _get_action_add_from_catalog_extra_context(self):
return {
**super()._get_action_add_from_catalog_extra_context(),
'product_catalog_currency_id': self.env.company.currency_id.id,
}
def _default_order_line_values(self, child_field=False):
default_data = super()._default_order_line_values(child_field)
new_default_data = self[child_field]._get_product_catalog_lines_data(default=True)
return {**default_data, **new_default_data}
def _get_product_catalog_order_data(self, products, **kwargs):
product_catalog = super()._get_product_catalog_order_data(products, **kwargs)
for product in products:
product_catalog[product.id] |= self._get_product_price_and_data(product)
return product_catalog
def _get_product_price_and_data(self, product):
self.ensure_one()
return {'price': product.standard_price}
def _get_product_catalog_record_lines(self, product_ids, *, child_field=False, **kwargs):
if not child_field:
return {}
lines = self[child_field].filtered(lambda line: line.product_id.id in product_ids)
return lines.grouped('product_id')
def _update_order_line_info(self, product_id, quantity, *, child_field=False, **kwargs):
if not child_field:
return 0
entity = self[child_field].filtered(lambda line: line.product_id.id == product_id)
if entity:
if quantity != 0:
entity.product_qty = quantity
else:
entity.unlink()
elif quantity > 0:
command = Command.create({
'product_qty': quantity,
'product_id': product_id,
})
self.write({child_field: [command]})
return self.env['product.product'].browse(product_id).standard_price
# -------------------------------------------------------------------------
# DOCUMENT
# -------------------------------------------------------------------------
def _get_mail_thread_data_attachments(self):
res = super()._get_mail_thread_data_attachments()
return res | self._get_extra_attachments()
def _get_extra_attachments(self):
is_byproduct = self.env.user.has_group('mrp.group_mrp_byproducts')
product_ids, template_ids = OrderedSet(), OrderedSet()
for bom in self:
product_ids.add(bom.product_id.id)
template_ids.add(bom.product_tmpl_id.id)
if is_byproduct:
product_ids.update(bom.byproduct_ids.product_id.ids)
template_ids.update(bom.byproduct_ids.product_id.product_tmpl_id.ids)
domain = Domain('attached_on_mrp', '=', 'bom') & (
(Domain('res_model', '=', 'product.product') & Domain('res_id', 'in', product_ids))
| (Domain('res_model', '=', 'product.template') & Domain('res_id', 'in', template_ids))
)
attachements = self.env['product.document'].search(domain).ir_attachment_id
return attachements
@api.model
def _skip_for_no_variant(self, product, bom_attribule_values, never_attribute_values=False):
""" Controls if a Component/Operation/Byproduct line should be skipped based on the 'no_variant' attributes
Cases:
- no_variant:
1. attribute present on the line
=> need to be at least one attribute value matching between the one passed as args and the ones one the line
2. attribute not present on the line
=> valid if the line has no attribute value selected for that attribute
- always and dynamic: match_all_variant_values()
"""
no_variant_bom_attributes = bom_attribule_values.filtered(lambda av: av.attribute_id.create_variant == 'no_variant')
# Attributes create_variant 'always' and 'dynamic'
other_attribute_valid = product._match_all_variant_values(bom_attribule_values - no_variant_bom_attributes)
# If there are no never attribute values on the line => 'always' and 'dynamic'
if not no_variant_bom_attributes:
return not other_attribute_valid
# Or if there are never attribute on the line values but no value is passed => impossible to match
if not never_attribute_values:
return True
bom_values_by_attribute = no_variant_bom_attributes.grouped('attribute_id')
never_values_by_attribute = never_attribute_values.grouped('attribute_id')
# Or if there is no overlap between given line values attributes and the ones on on the bom
if not any(never_att_id in no_variant_bom_attributes.attribute_id.ids for never_att_id in never_attribute_values.attribute_id.ids):
return True
# Check that at least one variant attribute is correct
for attribute, values in bom_values_by_attribute.items():
if never_values_by_attribute.get(attribute) and any(val.id in never_values_by_attribute[attribute].ids for val in values):
return not other_attribute_valid
# None were found, so we skip the line
return True
# -------------------------------------------------------------------------
# REPLENISHMENT WIZARD
# -------------------------------------------------------------------------
def _compute_show_set_bom_button(self):
self.show_set_bom_button = True
orderpoint_id = self.env.context.get('orderpoint_id', self.env.context.get('default_orderpoint_id'))
if orderpoint_id:
orderpoint = self.env['stock.warehouse.orderpoint'].browse(orderpoint_id)
self.filtered(
lambda s: s.id == orderpoint.bom_id.id
).show_set_bom_button = False
def action_set_bom_on_orderpoint(self):
self.ensure_one()
orderpoint_id = self.env.context.get('orderpoint_id')
if not orderpoint_id:
return
orderpoint = self.env['stock.warehouse.orderpoint'].browse(orderpoint_id)
if 'manufacture' not in orderpoint.route_id.rule_ids.mapped('action'):
domain = Domain.AND([
[('action', '=', 'manufacture')],
Domain.OR([
[('company_id', '=', orderpoint.company_id.id)],
[('company_id', '=', False)],
]),
])
orderpoint.route_id = self.env['stock.rule'].search(domain, limit=1).route_id.id
orderpoint.bom_id = self
bom_qty = self.product_uom_id._compute_quantity(self.product_qty, orderpoint.product_id.uom_id)
if orderpoint.qty_to_order < bom_qty:
orderpoint.qty_to_order = bom_qty
return orderpoint.action_stock_replenishment_info()
def action_open_operation_form(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'mrp.routing.workcenter',
'context': {
'default_bom_id': self.id,
'search_default_bom_id': self.id,
'bom_id_invisible': True,
},
}
def action_copy_existing_operations(self):
self.ensure_one()
return self.env['mrp.routing.workcenter'].with_context(bom_id=self.id).copy_existing_operations()
class MrpBomLine(models.Model):
_name = 'mrp.bom.line'
@ -414,19 +674,16 @@ class MrpBomLine(models.Model):
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_id = fields.Many2one('product.product', 'Component', required=True, check_company=True, index=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)
digits='Product Unit', 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')
'uom.uom', 'Unit',
default=_get_default_product_uom_id, required=True)
sequence = fields.Integer(
'Sequence', default=1,
help="Gives the sequence order when displaying.")
@ -451,29 +708,11 @@ class MrpBomLine(models.Model):
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)
_bom_qty_zero = models.Constraint(
'CHECK (product_qty>=0)',
'All product quantities must be greater or equal to 0.\nLines with 0 quantities can be used as optional lines. \nYou should install the mrp_byproduct module if you want to manage extra products on BoMs!',
)
@api.depends('product_id', 'bom_id')
def _compute_child_bom_id(self):
@ -488,10 +727,11 @@ class MrpBomLine(models.Model):
@api.depends('product_id')
def _compute_attachments_count(self):
for line in self:
nbr_attach = self.env['mrp.document'].search_count([
nbr_attach = self.env['product.document'].search_count([
'&', '&', ('attached_on_mrp', '=', 'bom'), ('active', '=', 't'),
'|',
'&', ('res_model', '=', 'product.product'), ('res_id', '=', line.product_id.id),
'&', ('res_model', '=', 'product.template'), ('res_id', '=', line.product_id.product_tmpl_id.id)])
'&', ('res_model', '=', 'product.template'), ('res_id', '=', line.product_tmpl_id.id)])
line.attachments_count = nbr_attach
@api.depends('child_bom_id')
@ -500,16 +740,6 @@ class MrpBomLine(models.Model):
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:
@ -522,40 +752,91 @@ class MrpBomLine(models.Model):
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.
def _skip_bom_line(self, product, never_attribute_values=False):
""" Control if a BoM line should be produced, can be inherited to add custom control.
cases:
- no_variant:
1. attribute present on the line
=> need to be at least one attribute value matching between the one passed as args and the ones one the line
2. attribute not present on the line
=> valid if the line has no attribute value selected for that attribute
- always and dynamic: match_all_variant_values()
"""
self.ensure_one()
if product._name == 'product.template':
if not product or product._name == 'product.template':
return False
return not product._match_all_variant_values(self.bom_product_template_attribute_value_ids)
return self.env['mrp.bom']._skip_for_no_variant(product, self.bom_product_template_attribute_value_ids, never_attribute_values)
def action_see_attachments(self):
domain = [
'&', ('attached_on_mrp', '=', 'bom'),
'|',
'&', ('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')
attachments = self.env['product.document'].search(domain)
nbr_product_attach = len(attachments.filtered(lambda a: a.res_model == 'product.product'))
nbr_template_attach = len(attachments.filtered(lambda a: a.res_model == 'product.template'))
context = {'default_res_model': 'product.product',
'default_res_id': self.product_id.id,
'default_company_id': self.company_id.id,
'attached_on_bom': True,
'search_default_context_variant': not (nbr_product_attach == 0 and nbr_template_attach > 0) if self.env.user.has_group('product.group_product_variant') else False
}
return {
'name': _('Attachments'),
'domain': domain,
'res_model': 'mrp.document',
'res_model': 'product.document',
'type': 'ir.actions.act_window',
'view_id': attachment_view.id,
'views': [(attachment_view.id, 'kanban'), (False, 'form')],
'view_mode': 'kanban,tree,form',
'view_mode': 'kanban,list,form',
'target': 'current',
'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)
'context': context,
'search_view_id': self.env.ref('product.product_document_search').ids
}
# -------------------------------------------------------------------------
# CATALOG
# -------------------------------------------------------------------------
class MrpByProduct(models.Model):
def action_add_from_catalog(self):
bom = self.env['mrp.bom'].browse(self.env.context.get('order_id'))
return bom.with_context(child_field='bom_line_ids').action_add_from_catalog()
def _get_product_catalog_lines_data(self, default=False, **kwargs):
if self and not default:
self.product_id.ensure_one()
return {
**self[0].bom_id._get_product_price_and_data(self[0].product_id),
'quantity': sum(
self.mapped(
lambda line: line.product_uom_id._compute_quantity(
qty=line.product_qty,
to_unit=line.product_uom_id,
)
)
),
'readOnly': len(self) > 1,
'uomDisplayName': len(self) == 1 and self.product_uom_id.display_name or self.product_id.uom_id.display_name,
}
return {
'quantity': 0,
}
def _prepare_bom_done_values(self, quantity, product, original_quantity, boms_done):
return {'qty': quantity, 'product': product, 'original_qty': original_quantity, 'parent_line': self}
def _prepare_line_done_values(self, quantity, product, original_quantity, parent_line, boms_done):
return {'qty': quantity, 'product': product, 'original_qty': original_quantity, 'parent_line': parent_line}
class MrpBomByproduct(models.Model):
_name = 'mrp.bom.byproduct'
_description = 'Byproduct'
_rec_name = "product_id"
@ -566,11 +847,9 @@ class MrpByProduct(models.Model):
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)]")
default=1.0, digits='Product Unit', required=True)
product_uom_id = fields.Many2one('uom.uom', 'Unit', required=True,
compute="_compute_product_uom_id", store=True, readonly=False, precompute=True)
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(
@ -593,11 +872,40 @@ class MrpByProduct(models.Model):
for record in self:
record.product_uom_id = record.product_id.uom_id.id
def _skip_byproduct_line(self, product):
def _skip_byproduct_line(self, product, never_attribute_values=False):
""" Control if a byproduct line should be produced, can be inherited to add
custom control.
"""
self.ensure_one()
if product._name == 'product.template':
if not product or product._name == 'product.template':
return False
return not product._match_all_variant_values(self.bom_product_template_attribute_value_ids)
return self.env['mrp.bom']._skip_for_no_variant(product, self.bom_product_template_attribute_value_ids, never_attribute_values)
# -------------------------------------------------------------------------
# CATALOG
# -------------------------------------------------------------------------
def action_add_from_catalog(self):
bom = self.env['mrp.bom'].browse(self.env.context.get('order_id'))
return bom.with_context(child_field='byproduct_ids').action_add_from_catalog()
def _get_product_catalog_lines_data(self, default=False, **kwargs):
if self and not default:
self.product_id.ensure_one()
return {
**self[0].bom_id._get_product_price_and_data(self[0].product_id),
'quantity': sum(
self.mapped(
lambda line: line.product_uom_id._compute_quantity(
qty=line.product_qty,
to_unit=line.product_uom_id,
)
)
),
'readOnly': len(self) > 1,
'uomDisplayName': len(self) == 1 and self.product_uom_id.display_name or self.product_id.uom_id.display_name,
}
return {
'quantity': 0,
}

View file

@ -1,36 +0,0 @@
# -*- 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

@ -1,19 +1,22 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _, tools
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
from odoo.tools import float_round, float_is_zero
class MrpRoutingWorkcenter(models.Model):
_name = 'mrp.routing.workcenter'
_description = 'Work Center Usage'
_inherit = ['mail.thread', 'mail.activity.mixin']
_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)
workcenter_id = fields.Many2one('mrp.workcenter', 'Work Center', required=True, check_company=True, tracking=True, index=True)
sequence = fields.Integer(
'Sequence', default=100,
help="Gives the sequence order when displaying a list of routing Work Centers.")
@ -21,25 +24,18 @@ class MrpRoutingWorkcenter(models.Model):
'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')
('manual', 'Fixed'),
('auto', 'Computed')], string='Duration Computation',
default='manual', tracking=True)
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,
'Manual Duration', default=60, tracking=True,
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")
"- In fixed mode, time used"
"- In computed mode, supposed first time when there aren't any work orders yet")
time_cycle = fields.Float('Cycles', 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')
@ -58,20 +54,33 @@ class MrpRoutingWorkcenter(models.Model):
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)
cycle_number = fields.Integer("Repetitions", compute="_compute_time_cycle")
time_total = fields.Float('Total Duration', compute="_compute_time_cycle")
show_time_total = fields.Boolean('Show Total Duration?', compute="_compute_time_cycle")
cost_mode = fields.Selection([('actual', 'Actual time'), ('estimated', 'Theorical time')],
string='Cost based on', default='actual', tracking=True,
help="Determines the way Odoo calculates the cost of the operation:\n"
"- Based on Actual time: the cost will be calculated based on tracked time and real employee costs.\n"
"- Based on Estimated time: the cost will be calculated based on estimated time and costs.")
cost = fields.Float('Cost', compute="_compute_cost")
@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
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')
@api.depends('time_cycle_manual', 'time_mode', 'workorder_ids',
'bom_id.product_id', 'bom_id.product_qty',
'workcenter_id.time_start', 'workcenter_id.time_stop', 'workcenter_id.capacity_ids'
)
@api.depends_context('product', 'quantity', 'unit', 'workcenter')
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),
('operation_id', 'in', operation.ids),
('qty_produced', '>', 0),
('state', '=', 'done')],
limit=operation.time_mode_batch,
@ -85,33 +94,74 @@ class MrpRoutingWorkcenter(models.Model):
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')
(capacity, _setup, _cleanup) = item['workcenter_id']._get_capacity(item.product_id, item.product_uom_id, operation.bom_id.product_qty or 1)
cycle_number += float_round((item['qty_produced'] / capacity), precision_digits=0, rounding_method='UP')
if cycle_number:
operation.time_cycle = total_duration / cycle_number
else:
operation.time_cycle = operation.time_cycle_manual
for operation in self:
workcenter = self.env.context.get('workcenter', operation.workcenter_id)
product = self.env.context.get('product', operation.bom_id.product_id or operation.bom_id.product_tmpl_id.product_variant_ids)
if len(product) > 1:
operation.cycle_number = 1
operation.time_total = workcenter.time_start + workcenter.time_stop + operation.time_cycle_manual
operation.show_time_total = False
continue
quantity = self.env.context.get('quantity', operation.bom_id.product_qty or 1)
unit = self.env.context.get('unit', operation.bom_id.product_uom_id)
(capacity, setup, cleanup) = workcenter._get_capacity(product, unit, operation.bom_id.product_qty or 1)
operation.cycle_number = float_round(quantity / capacity, precision_digits=0, rounding_method="UP")
operation.time_total = setup + cleanup + operation.cycle_number * operation.time_cycle * 100.0 / (workcenter.time_efficiency or 100.0)
operation.show_time_total = operation.cycle_number > 1 or not float_is_zero(setup + cleanup, precision_digits=0)
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)
('state', '=', 'done')], ['operation_id'], ['__count'])
count_data = {operation.id: count for operation, count in data}
for operation in self:
operation.workorder_count = count_data.get(operation.id, 0)
@api.depends('time_total', 'workcenter_id')
@api.depends_context('product', 'quantity', 'unit', 'workcenter')
def _compute_cost(self):
for operation in self:
operation.cost = (operation.time_total / 60.0) * operation.workcenter_id.costs_hour
@api.constrains('blocked_by_operation_ids')
def _check_no_cyclic_dependencies(self):
if not self._check_m2m_recursion('blocked_by_operation_ids'):
if self._has_cycle('blocked_by_operation_ids'):
raise ValidationError(_("You cannot create cyclic dependency."))
@api.model_create_multi
def create(self, vals_list):
res = super().create(vals_list)
res.bom_id._set_outdated_bom_in_productions()
return res
def write(self, vals):
self.bom_id._set_outdated_bom_in_productions()
if 'bom_id' in vals:
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(vals)
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})
self.bom_id._set_outdated_bom_in_productions()
return res
def action_unarchive(self):
res = super().action_unarchive()
self.bom_id._set_outdated_bom_in_productions()
return res
def copy_to_bom(self):
@ -132,15 +182,15 @@ class MrpRoutingWorkcenter(models.Model):
'type': 'ir.actions.act_window',
'name': _('Select Operations to Copy'),
'res_model': 'mrp.routing.workcenter',
'view_mode': 'tree,form',
'view_mode': 'list,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',
'list_view_ref': 'mrp.mrp_routing_workcenter_copy_to_bom_tree_view',
}
}
def _skip_operation_line(self, product):
def _skip_operation_line(self, product, never_attribute_values=False):
""" Control if a operation should be processed, can be inherited to add
custom control.
"""
@ -148,20 +198,14 @@ class MrpRoutingWorkcenter(models.Model):
# skip operation line if archived
if not self.active:
return True
if product._name == 'product.template':
if not product or 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'))
return self.env['mrp.bom']._skip_for_no_variant(product, self.bom_product_template_attribute_value_ids, never_attribute_values)
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)
def action_open_operation_form(self):
return {
'type': 'ir.actions.act_window',
'view_mode': 'form',
'res_model': 'mrp.routing.workcenter',
}

View file

@ -1,36 +1,38 @@
# -*- 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
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools import float_compare, float_round
from odoo.tools.misc import clean_context
class MrpUnbuild(models.Model):
_name = "mrp.unbuild"
_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'))
name = fields.Char('Reference', copy=False, readonly=True, default=lambda s: s.env._('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)]})
domain="[('type', '=', 'consu')]",
compute='_compute_product_id', store=True, precompute=True, readonly=False,
required=True)
company_id = fields.Many2one(
'res.company', 'Company',
default=lambda s: s.env.company,
required=True, index=True, states={'done': [('readonly', True)]})
required=True, index=True)
product_qty = fields.Float(
'Quantity', default=1.0,
required=True, states={'done': [('readonly', True)]})
digits='Product Unit',
compute='_compute_product_qty', store=True, precompute=True, readonly=False,
required=True)
product_uom_id = fields.Many2one(
'uom.uom', 'Unit of Measure',
'uom.uom', 'Unit',
compute='_compute_product_uom_id', store=True, readonly=False, precompute=True,
required=True, states={'done': [('readonly', True)]})
required=True)
bom_id = fields.Many2one(
'mrp.bom', 'Bill of Material',
domain="""[
@ -43,30 +45,31 @@ class MrpUnbuild(models.Model):
'|',
('company_id', '=', company_id),
('company_id', '=', False)
]
""",
states={'done': [('readonly', True)]}, check_company=True)
]""",
compute='_compute_bom_id', store=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)
domain="[('state', '=', 'done'), ('product_id', '=?', product_id), ('bom_id', '=?', bom_id)]",
check_company=True, index='btree_not_null')
mo_bom_id = fields.Many2one('mrp.bom', 'Bill of Material used on the Production Order', related='mo_id.bom_id')
lot_producing_ids = fields.Many2many('stock.lot', string='Lot/Serial Numbers', related='mo_id.lot_producing_ids')
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)
domain="[('product_id', '=', product_id),('id', 'in', lot_producing_ids)]", 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)]",
domain="[('usage','=','internal')]",
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.")
required=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)]",
domain="[('usage','=','internal')]",
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.")
required=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')
@ -77,6 +80,11 @@ class MrpUnbuild(models.Model):
('draft', 'Draft'),
('done', 'Done')], string='Status', default='draft')
_qty_positive = models.Constraint(
'check (product_qty > 0)',
'The quantity to unbuild must be positive!',
)
@api.depends('mo_id', 'product_id')
def _compute_product_uom_id(self):
for record in self:
@ -95,30 +103,30 @@ class MrpUnbuild(models.Model):
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
@api.depends('mo_id', 'product_id', 'company_id')
def _compute_bom_id(self):
for order in self:
if order.mo_id:
order.bom_id = order.mo_id.bom_id
else:
self.product_qty = self.mo_id.qty_produced
order.bom_id = self.env['mrp.bom']._bom_find(
order.product_id, company_id=order.company_id.id
)[order.product_id]
@api.depends('mo_id')
def _compute_product_id(self):
for order in self:
if order.mo_id and order.mo_id.product_id:
order.product_id = order.mo_id.product_id
@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.depends('mo_id')
def _compute_product_qty(self):
for order in self:
if order.mo_id:
if order.has_tracking == 'serial':
order.product_qty = 1
else:
order.product_qty = order.mo_id.qty_produced
@api.model_create_multi
def create(self, vals_list):
@ -136,7 +144,7 @@ class MrpUnbuild(models.Model):
return {
'move_id': finished_move.id,
'lot_id': self.lot_id.id,
'qty_done': finished_move.product_uom_qty,
'quantity': finished_move.product_uom_qty - finished_move.quantity,
'product_id': finished_move.product_id.id,
'product_uom_id': finished_move.product_uom.id,
'location_id': finished_move.location_id.id,
@ -147,7 +155,7 @@ class MrpUnbuild(models.Model):
return {
'move_id': move.id,
'lot_id': origin_move_line.lot_id.id,
'qty_done': taken_quantity,
'quantity': taken_quantity,
'product_id': move.product_id.id,
'product_uom_id': origin_move_line.product_uom_id.id,
'location_id': move.location_id.id,
@ -157,70 +165,86 @@ class MrpUnbuild(models.Model):
def action_unbuild(self):
self.ensure_one()
self._check_company()
# remove the default_* keys that were only needed in the unbuild wizard
self = self.with_env(self.env(context=clean_context(self.env.context))) # noqa: PLW0642
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.'))
if self.mo_id and 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()
produce_moves._action_confirm()
produce_moves.quantity = 0
# Collect component lots already restored by previous unbuilds on the same MO
previously_unbuilt_lots = (self.mo_id.unbuild_ids - self).produce_line_ids.filtered(lambda ml: ml.product_id != self.product_id and ml.product_id.tracking == 'serial').lot_ids
finished_moves = consume_moves.filtered(lambda m: m.product_id == self.product_id)
consume_moves -= finished_moves
error_message = _(
"Please specify a manufacturing order.\n"
"It will allow us to retrieve the lots/serial numbers of the correct components and/or byproducts."
)
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.'))
raise UserError(error_message)
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.'))
raise UserError(error_message)
for finished_move in finished_moves:
if finished_move.has_tracking != 'none':
if float_compare(finished_move.product_uom_qty, finished_move.quantity, precision_rounding=finished_move.product_uom.rounding) > 0:
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
self.env['stock.move.line'].create(finished_move_line_vals)
# 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)
if float_compare(move.product_uom_qty, move.quantity, precision_rounding=move.product_uom.rounding) < 1:
continue
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)
if not original_move:
move.quantity = move.product_uom.round(move.product_uom_qty)
continue
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 and ml.lot_id not in previously_unbuilt_lots
)
for move_line in moves_lines:
# Iterate over all move_lines until we unbuilded the correct quantity.
taken_quantity = min(needed_quantity, move_line.quantity - qty_already_used[move_line])
taken_quantity = move.product_uom.round(taken_quantity)
if taken_quantity:
move_line_vals = self._prepare_move_line_vals(move, move_line, taken_quantity)
if move_line.owner_id:
move_line_vals['owner_id'] = move_line.owner_id.id
unbuild_move_line = self.env["stock.move.line"].create(move_line_vals)
needed_quantity -= taken_quantity
qty_already_used[move_line] += taken_quantity
unbuild_move_line._apply_putaway_strategy()
(finished_moves | consume_moves | produce_moves).picked = True
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)
produced_move_line_ids = produce_moves.mapped('move_line_ids').filtered(lambda ml: ml.quantity > 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",
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)
subtype_xmlid='mail.mt_note',
)
return self.write({'state': 'done'})
def _generate_consume_moves(self):
@ -228,8 +252,7 @@ class MrpUnbuild(models.Model):
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
factor = unbuild.product_qty / unbuild.mo_id.product_uom_id._compute_quantity(unbuild.mo_id.qty_produced, unbuild.product_uom_id)
for finished_move in finished_moves:
moves += unbuild._generate_move_from_existing_move(finished_move, factor, unbuild.location_id, finished_move.location_id)
else:
@ -259,10 +282,9 @@ class MrpUnbuild(models.Model):
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_qty': move.quantity * factor,
'product_uom': move.product_uom.id,
'procure_method': 'make_to_stock',
'location_dest_id': location_dest_id.id,
@ -279,7 +301,6 @@ class MrpUnbuild(models.Model):
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,
@ -296,14 +317,14 @@ class MrpUnbuild(models.Model):
def action_validate(self):
self.ensure_one()
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
precision = self.env['decimal.precision'].precision_get('Product Unit')
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'),
'name': _('%(product)s: Insufficient Quantity To Unbuild', product=self.product_id.display_name),
'view_mode': 'form',
'res_model': 'stock.warn.insufficient.qty.unbuild',
'view_id': self.env.ref('mrp.stock_warn_insufficient_qty_unbuild_form_view').id,
@ -313,7 +334,7 @@ class MrpUnbuild(models.Model):
'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
'default_product_uom_name': self.product_id.uom_name,
},
'target': 'new'
'target': 'new',
}

View file

@ -1,6 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from babel.dates import format_date
from collections import defaultdict
from dateutil import relativedelta
from datetime import timedelta, datetime
from functools import partial
@ -9,15 +12,17 @@ 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
from odoo.tools.intervals import Intervals
from odoo.tools.date_utils import start_of, end_of, localized, to_timezone
from odoo.tools.float_utils import float_compare, float_is_zero, float_round
from odoo.tools.misc import get_lang
class MrpWorkcenter(models.Model):
_name = 'mrp.workcenter'
_description = 'Work Center'
_order = "sequence, id"
_inherit = ['resource.mixin']
_inherit = ['mail.thread', 'resource.mixin']
_check_company_auto = True
# resource
@ -28,23 +33,21 @@ class MrpWorkcenter(models.Model):
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)
costs_hour = fields.Float(string='Cost per hour', help='Hourly processing cost.', default=0.0, tracking=True)
time_start = fields.Float('Setup Time')
time_stop = fields.Float('Cleanup Time')
routing_line_ids = fields.One2many('mrp.routing.workcenter', 'workcenter_id', "Routing Lines")
has_routing_lines = fields.Boolean(compute='_compute_has_routing_lines', help='Technical field for workcenter views')
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_ready_count = fields.Integer('# To Do 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_blocked_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')
@ -74,6 +77,15 @@ class MrpWorkcenter(models.Model):
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)
kanban_dashboard_graph = fields.Text(compute='_compute_kanban_dashboard_graph')
resource_calendar_id = fields.Many2one(check_company=True)
def _compute_display_name(self):
super()._compute_display_name()
for workcenter in self:
# Show the red icon(workcenter is blocked) only when the Gantt view is accessed from MRP > Planning > Planning by Workcenter.
if self.env.context.get('group_by') and self.env.context.get('show_workcenter_status') and workcenter.working_state == 'blocked':
workcenter.display_name = f"{workcenter.display_name}\u00A0\u00A0🔴"
@api.constrains('alternative_workcenter_ids')
def _check_alternative_workcenter(self):
@ -81,28 +93,99 @@ class MrpWorkcenter(models.Model):
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_kanban_dashboard_graph(self):
week_range, date_start, date_stop = self._get_week_range_and_first_last_days()
load_data = self._get_workcenter_load_per_week(week_range, date_start, date_stop)
load_graph_data = self._prepare_graph_data(load_data, week_range)
for wc in self:
wc.kanban_dashboard_graph = json.dumps(load_graph_data[wc.id])
def _get_week_range_and_first_last_days(self):
""" We calculate the delta between today and the previous monday,
then add it to the delta between monday and the previous first day
of the week as configured in the language settings.
We use the result to calculate the modulo of 7 to make sure that
we do not take the previous first day of the week from 2 weeks ago.
E.g. today is Thursday, the first of a week is a Tuesday.
The delta between today and Monday is 3 days.
The delta between Monday and the previous Tuesday is 6 days.
(3 + 6) % 7 = 2, so from today, the first day of the current week is 2 days ago.
"""
week_range = {}
locale = get_lang(self.env).code
today = datetime.today()
delta_from_monday_to_today = (today - start_of(today, 'week')).days
first_week_day = int(get_lang(self.env).week_start) - 1
day_offset = ((7 - first_week_day) + delta_from_monday_to_today) % 7
for delta in range(-7, 28, 7):
week_start = start_of(today + relativedelta.relativedelta(days=delta - day_offset), 'day')
week_end = week_start + relativedelta.relativedelta(days=6)
short_name = (format_date(week_start, 'd - ', locale=locale)
+ format_date(week_end, 'd MMM', locale=locale))
if not delta:
short_name = _('This Week')
week_range[week_start] = short_name
date_start = start_of(today + relativedelta.relativedelta(days=-7 - day_offset), 'day')
date_stop = end_of(today + relativedelta.relativedelta(days=27 - day_offset), 'day')
return week_range, date_start, date_stop
def _get_workcenter_load_per_week(self, week_range, date_start, date_stop):
load_data = {rec: {} for rec in self}
# demo data
has_workorder = self.env['mrp.workorder'].search_count([('workcenter_id', 'in', self.ids)], limit=1)
if not has_workorder:
for wc in self:
load_limit = 40 # default max load per week is 40 hours on a new workcenter
load_data[wc] = {week_start: randint(0, int(load_limit * 2)) for week_start in week_range}
return load_data
result = self.env['mrp.workorder']._read_group(
[('workcenter_id', 'in', self.ids), ('state', 'in', ('pending', 'waiting', 'ready', 'progress')),
('production_date', '>=', date_start), ('production_date', '<=', date_stop)],
['workcenter_id', 'production_date:week'], ['duration_expected:sum'])
for r in result:
load_in_hours = round(r[2] / 60, 1)
load_data[r[0]].update({r[1]: load_in_hours})
return load_data
def _prepare_graph_data(self, load_data, week_range):
graph_data = {wid: [] for wid in self._ids}
has_workorder = self.env['mrp.workorder'].search_count([('workcenter_id', 'in', self.ids)], limit=1)
for workcenter in self:
load_limit = sum(workcenter.resource_calendar_id.attendance_ids.mapped('duration_hours'))
wc_data = {'is_sample_data': not has_workorder, 'labels': list(week_range.values())}
load_bar = []
excess_bar = []
for week_start in week_range:
load_bar.append(min(load_data[workcenter].get(week_start, 0), load_limit))
excess_bar.append(max(float_round(load_data[workcenter].get(week_start, 0) - load_limit, precision_digits=1, rounding_method='HALF-UP'), 0))
wc_data['values'] = [load_bar, load_limit, excess_bar]
graph_data[workcenter.id].append(wc_data)
return graph_data
@api.depends('order_ids.duration_expected', 'order_ids.workcenter_id', 'order_ids.state', 'order_ids.date_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)
[('workcenter_id', 'in', self.ids), ('state', 'in', ('blocked', 'ready')), ('date_start', '<', datetime.now().strftime('%Y-%m-%d'))],
['workcenter_id'], ['__count'])
count_data = {workcenter.id: count for workcenter, count 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']
['workcenter_id', 'state'], ['duration_expected:sum', '__count'])
for workcenter, state, duration_sum, count in res:
result[workcenter.id][state] = count
if state in ('blocked', 'ready', 'progress'):
result_duration_expected[workcenter.id] += duration_sum
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.workorder_blocked_count = result[workcenter.id].get('blocked', 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)
@ -110,14 +193,20 @@ class MrpWorkcenter(models.Model):
@api.depends('time_ids', 'time_ids.date_end', 'time_ids.loss_type')
def _compute_working_state(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_by_workcenter = {}
for time_log in self.env['mrp.workcenter.productivity'].search([
('workcenter_id', 'in', self.ids),
('date_end', '=', False),
]):
wc = time_log.workcenter_id
if wc not in time_log_by_workcenter:
time_log_by_workcenter[wc] = time_log
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)
time_log = time_log_by_workcenter.get(workcenter._origin)
if not time_log:
# the workcenter is not being used
workcenter.working_state = 'normal'
@ -135,8 +224,8 @@ class MrpWorkcenter(models.Model):
('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)
['workcenter_id'], ['duration:sum'])
count_data = {workcenter.id: duration for workcenter, duration in data}
for workcenter in self:
workcenter.blocked_time = count_data.get(workcenter.id, 0.0) / 60.0
@ -147,36 +236,53 @@ class MrpWorkcenter(models.Model):
('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)
['workcenter_id'], ['duration:sum'])
count_data = {workcenter.id: duration for workcenter, duration 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)
time_data = self.env['mrp.workcenter.productivity']._read_group(
domain=[
('date_start', '>=', fields.Datetime.to_string(datetime.now() - relativedelta.relativedelta(months=1))),
('workcenter_id', 'in', self.ids),
('date_end', '!=', False),
],
groupby=['workcenter_id', 'loss_type'],
aggregates=['duration:sum'],
)
time_by_workcenter = defaultdict(lambda: {'productive_time': 0.0, 'blocked_time': 0.0})
for data in time_data:
workcenter, loss_type, duration = data
time_to_update = 'productive_time' if loss_type == 'productive' else 'blocked_time'
time_by_workcenter[workcenter.id][time_to_update] += duration
for workcenter in self:
workcenter_time = time_by_workcenter[workcenter.id]
productive_time = workcenter_time['productive_time']
if productive_time:
blocked_time = workcenter_time['blocked_time']
workcenter.oee = float_round(productive_time * 100.0 / (productive_time + blocked_time), precision_digits=2)
else:
order.oee = 0.0
workcenter.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)
('state', '=', 'done')], ['workcenter_id'], ['duration_expected:sum', 'duration:sum'])
duration_expected = {workcenter.id: expected for workcenter, expected, __ in wo_data}
duration = {workcenter.id: duration for workcenter, __, duration 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.'))
@api.depends('routing_line_ids')
def _compute_has_routing_lines(self):
for workcenter in self:
workcenter.has_routing_lines = self.env['mrp.routing.workcenter'].search_count([('workcenter_id', 'in', workcenter.ids)], limit=1)
def unblock(self):
self.ensure_one()
@ -184,7 +290,7 @@ class MrpWorkcenter(models.Model):
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'}
return True
@api.model_create_multi
def create(self, vals_list):
@ -211,6 +317,12 @@ class MrpWorkcenter(models.Model):
action = self.env["ir.actions.actions"]._for_xml_id("mrp.action_work_orders")
return action
def action_work_order_alternatives(self):
action = self.env["ir.actions.actions"]._for_xml_id("mrp.mrp_workorder_todo")
action['domain'] = ['|', ('workcenter_id', 'in', self.alternative_workcenter_ids.ids),
('workcenter_id.alternative_workcenter_ids', '=', self.id)]
return action
def _get_unavailability_intervals(self, start_datetime, end_datetime):
"""Get the unavailabilities intervals for the workcenters in `self`.
@ -224,7 +336,7 @@ class MrpWorkcenter(models.Model):
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):
def _get_first_available_slot(self, start_datetime, duration, forward=True, leaves_to_ignore=False, extra_leaves_slots=[]):
"""Get the first available interval for the workcenter in `self`.
The available interval is disjoinct with all other workorders planned on this workcenter, but
@ -232,44 +344,68 @@ class MrpWorkcenter(models.Model):
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)
:param start_datetime: begin the search at this datetime
:param forward: forward scheduling (search from start_datetime to 700 days after), or backward (from start_datetime to now)
:param leaves_to_ignore: typically, ignore allocated leave when re-planning a workorder
:param extra_leaves_slots: extra time slots (start, stop) to consider
:rtype: tuple
"""
self.ensure_one()
start_datetime, revert = make_aware(start_datetime)
ICP = self.env['ir.config_parameter'].sudo()
max_planning_iterations = max(int(ICP.get_param('mrp.workcenter_max_planning_iterations', '50')), 1)
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))
revert = to_timezone(start_datetime.tzinfo)
start_datetime = localized(start_datetime)
get_available_intervals = partial(self.resource_calendar_id._work_intervals_batch, resources=resource, tz=timezone(self.resource_calendar_id.tz))
workorder_intervals_leaves_domain = [('time_type', '=', 'other')]
if leaves_to_ignore:
workorder_intervals_leaves_domain.append(('id', 'not in', leaves_to_ignore.ids))
get_workorder_intervals = partial(self.resource_calendar_id._leave_intervals_batch, domain=workorder_intervals_leaves_domain, resources=resource, tz=timezone(self.resource_calendar_id.tz))
extra_leaves_slots_intervals = Intervals([(localized(start), localized(stop), self.env['resource.calendar.attendance']) for start, stop in extra_leaves_slots])
remaining = duration
start_interval = start_datetime
remaining = duration = max(duration, 1 / 60)
now = localized(datetime.now())
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):
start_interval, stop_interval = None, None
for n in range(max_planning_iterations): # 50 * 14 = 700 days in advance
if forward:
date_start = start_datetime + delta * n
date_stop = date_start + delta
available_intervals = get_available_intervals(date_start, date_stop)[resource.id]
workorder_intervals = get_workorder_intervals(date_start, date_stop)[resource.id]
for start, stop, _records in available_intervals:
start_interval = start_interval or start
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:
while (interval := Intervals([(start_interval or start, start + timedelta(minutes=min(remaining, interval_minutes)), _records)])) \
and (conflict := interval & workorder_intervals or interval & extra_leaves_slots_intervals):
(_start, start, _records) = conflict._items[0] # restart available interval at conflicting interval stop
interval_minutes = (stop - start).total_seconds() / 60
start_interval, remaining = start if interval_minutes else None, duration
if 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'
remaining -= interval_minutes
else:
# same process but starting from end on reversed intervals
date_stop = start_datetime - delta * n
date_start = date_stop - delta
available_intervals = get_available_intervals(date_start, date_stop)[resource.id]
available_intervals = reversed(available_intervals)
workorder_intervals = get_workorder_intervals(date_start, date_stop)[resource.id]
for start, stop, _records in available_intervals:
stop_interval = stop_interval or stop
interval_minutes = (stop - start).total_seconds() / 60
while (interval := Intervals([(stop - timedelta(minutes=min(remaining, interval_minutes)), stop_interval or stop, _records)])) \
and (conflict := interval & workorder_intervals or interval & extra_leaves_slots_intervals):
(stop, _stop, _records) = conflict._items[0] # restart available interval at conflicting interval start
interval_minutes = (stop - start).total_seconds() / 60
stop_interval, remaining = stop if interval_minutes else None, duration
if float_compare(interval_minutes, remaining, precision_digits=3) >= 0:
return revert(stop - timedelta(minutes=remaining)), revert(stop_interval)
remaining -= interval_minutes
if date_start <= now:
break
return False, 'No available slot 700 days after the planned start'
def action_archive(self):
res = super().action_archive()
@ -288,21 +424,20 @@ class MrpWorkcenter(models.Model):
}
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)
def _get_capacity(self, product, unit, default_capacity=1):
capacity = self.capacity_ids.sorted(lambda c: (
not (c.product_id == product and c.product_uom_id == product.uom_id),
not (not c.product_id and c.product_uom_id == unit),
not (not c.product_id and c.product_uom_id == product.uom_id),
))[:1]
if capacity and capacity.product_id in [product, self.env['product.product']] and capacity.product_uom_id in [product.uom_id, unit]:
if float_is_zero(capacity.capacity, 0):
return (default_capacity, capacity.time_start, capacity.time_stop)
return (capacity.product_uom_id._compute_quantity(capacity.capacity, unit), capacity.time_start, capacity.time_stop)
return (default_capacity, self.time_start, self.time_stop)
class WorkcenterTag(models.Model):
class MrpWorkcenterTag(models.Model):
_name = 'mrp.workcenter.tag'
_description = 'Add tag for the workcenter'
_order = 'name'
@ -313,27 +448,24 @@ class WorkcenterTag(models.Model):
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.'),
]
_tag_name_unique = models.Constraint(
'unique(name)',
'The tag name must be unique.',
)
class MrpWorkcenterProductivityLossType(models.Model):
_name = "mrp.workcenter.productivity.loss.type"
_name = 'mrp.workcenter.productivity.loss.type'
_description = 'MRP Workorder productivity losses'
_rec_name = 'loss_type'
@api.depends('loss_type')
def name_get(self):
def _compute_display_name(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
lower case. In order to display its value capitalized 'display_name' is
overrided.
"""
result = []
for rec in self:
result.append((rec.id, rec.loss_type.title()))
return result
rec.display_name = rec.loss_type.title()
loss_type = fields.Selection([
('availability', 'Availability'),
@ -343,15 +475,15 @@ class MrpWorkcenterProductivityLossType(models.Model):
class MrpWorkcenterProductivityLoss(models.Model):
_name = "mrp.workcenter.productivity.loss"
_name = 'mrp.workcenter.productivity.loss'
_description = "Workcenter Productivity Losses"
_order = "sequence, id"
name = fields.Char('Blocking Reason', required=True)
name = fields.Char('Blocking Reason', required=True, translate=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)
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', readonly=False)
def _convert_to_duration(self, date_start, date_stop, workcenter=False):
""" Convert a date range into a duration in minutes.
@ -368,8 +500,9 @@ class MrpWorkcenterProductivityLoss(models.Model):
duration = max(duration, (date_stop - date_start).total_seconds() / 60.0)
return round(duration, 2)
class MrpWorkcenterProductivity(models.Model):
_name = "mrp.workcenter.productivity"
_name = 'mrp.workcenter.productivity'
_description = "Workcenter Productivity Log"
_order = "id desc"
_rec_name = "loss_id"
@ -402,7 +535,7 @@ class MrpWorkcenterProductivity(models.Model):
'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)
string="Effectiveness", related='loss_id.loss_type', readonly=False)
description = fields.Text('Description')
date_start = fields.Datetime('Start Date', default=fields.Datetime.now, required=True)
date_end = fields.Datetime('End Date')
@ -416,17 +549,48 @@ class MrpWorkcenterProductivity(models.Model):
else:
blocktime.duration = 0.0
@api.onchange('duration')
def _duration_changed(self):
if not self.date_end:
return
self.date_start = self.date_end - timedelta(minutes=self.duration)
self._loss_type_change()
@api.onchange('date_start')
def _date_start_changed(self):
if not self.date_start:
return
self.date_end = self.date_start + timedelta(minutes=self.duration)
self._loss_type_change()
@api.onchange('date_end')
def _date_end_changed(self):
if not self.date_end:
return
self.date_start = self.date_end - timedelta(minutes=self.duration)
self._loss_type_change()
@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):
open_time_ids_by_user = self.env["mrp.workcenter.productivity"]._read_group(
[("id", "in", workorder.time_ids.ids), ("date_end", "=", False)],
["user_id"], having=[("__count", ">", 1)],
)
if 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 _loss_type_change(self):
self.ensure_one()
if self.workorder_id.duration > self.workorder_id.duration_expected:
self.loss_id = self.env.ref("mrp.block_reason4").id
else:
self.loss_id = self.env.ref("mrp.block_reason7").id
def _close(self):
underperformance_timers = self.env['mrp.workcenter.productivity']
for timer in self:
@ -446,19 +610,37 @@ class MrpWorkcenterProductivity(models.Model):
underperformance_timers.write({'loss_id': underperformance_type.id})
class MrpWorkCenterCapacity(models.Model):
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.")
def _default_time_start(self):
workcenter_id = self.workcenter_id.id or self.env.context.get('default_workcenter_id')
return self.env['mrp.workcenter'].browse(workcenter_id).time_start if workcenter_id else 0.0
_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.'),
]
def _default_time_stop(self):
workcenter_id = self.workcenter_id.id or self.env.context.get('default_workcenter_id')
return self.env['mrp.workcenter'].browse(workcenter_id).time_stop if workcenter_id else 0.0
workcenter_id = fields.Many2one('mrp.workcenter', string='Work Center', required=True, index=True)
product_id = fields.Many2one('product.product', string='Product')
product_uom_id = fields.Many2one('uom.uom', string='Unit',
compute="_compute_product_uom_id", precompute=True, store=True, readonly=False, required=True)
capacity = fields.Float('Capacity', help="Number of pieces that can be produced in parallel for this product or for all, depending on the unit.")
time_start = fields.Float('Setup Time (minutes)', default=_default_time_start, help="Time in minutes for the setup.")
time_stop = fields.Float('Cleanup Time (minutes)', default=_default_time_stop, help="Time in minutes for the cleaning.")
_positive_capacity = models.Constraint(
'CHECK(capacity >= 0)',
'Capacity should be a non-negative number.',
)
_workcenter_product_product_uom_unique = models.UniqueIndex(
'(workcenter_id, COALESCE(product_id, 0), product_uom_id)',
'Product/Unit capacity should be unique for each workcenter.'
)
@api.depends('product_id')
def _compute_product_uom_id(self):
for capacity in self:
capacity.product_uom_id = capacity.product_id.uom_id or self.env.ref('uom.product_uom_unit')

File diff suppressed because it is too large Load diff

View file

@ -1,24 +1,24 @@
# -*- 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
from odoo.exceptions import UserError
OPERATORS = {
PY_OPERATORS = {
'<': py_operator.lt,
'>': py_operator.gt,
'<=': py_operator.le,
'>=': py_operator.ge,
'=': py_operator.eq,
'!=': py_operator.ne
'!=': py_operator.ne,
'in': lambda elem, container: elem in container,
'not in': lambda elem, container: elem not in container,
}
class ProductTemplate(models.Model):
_inherit = "product.template"
@ -28,42 +28,31 @@ class ProductTemplate(models.Model):
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',
mrp_product_qty = fields.Float('Manufactured', digits='Product Unit',
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)])
product.bom_count = self.env['mrp.bom'].search_count(
['|', ('product_tmpl_id', 'in', product.ids), ('byproduct_ids.product_id.product_tmpl_id', 'in', product.ids)]
)
@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}
domain = [('product_tmpl_id', 'in', self.ids), ('type', '=', 'phantom'), '|', ('company_id', '=', False), ('company_id', '=', self.env.company.id)]
bom_mapping = self.env['mrp.bom'].sudo().search_read(domain, ['product_tmpl_id'])
kits_ids = set(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'
if operator != 'in':
return NotImplemented
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'))]
return [('id', 'in', bom_tmpl_query.subselect('product_tmpl_id'))]
def _compute_show_qty_status_button(self):
super()._compute_show_qty_status_button()
@ -72,17 +61,20 @@ class ProductTemplate(models.Model):
template.show_on_hand_qty_status_button = template.product_variant_count <= 1
template.show_forecasted_qty_status_button = False
def _should_open_product_quants(self):
return super()._should_open_product_quants() or self.is_kits
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)])
[('bom_line_ids.product_tmpl_id', 'in', template.ids)])
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']
def write(self, vals):
if 'active' in vals:
self.filtered(lambda p: p.active != vals['active']).with_context(active_test=False).bom_ids.write({
'active': vals['active']
})
return super().write(values)
return super().write(vals)
def action_used_in_bom(self):
self.ensure_one()
@ -92,22 +84,16 @@ class ProductTemplate(models.Model):
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)
template.mrp_product_qty = template.uom_id.round(sum(template.mapped('product_variant_ids').mapped('mrp_product_qty')))
def action_view_mos(self):
action = self.env["ir.actions.actions"]._for_xml_id("mrp.mrp_production_report")
action = self.env["ir.actions.actions"]._for_xml_id("mrp.mrp_production_action")
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()
@ -125,6 +111,10 @@ class ProductTemplate(models.Model):
}
return res
def _get_backend_root_menu_ids(self):
return super()._get_backend_root_menu_ids() + [self.env.ref('mrp.menu_mrp_root').id]
class ProductProduct(models.Model):
_inherit = "product.product"
@ -134,34 +124,50 @@ class ProductProduct(models.Model):
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',
mrp_product_qty = fields.Float('Manufactured', digits='Product Unit',
compute='_compute_mrp_product_qty', compute_sudo=False)
is_kits = fields.Boolean(compute="_compute_is_kits", search='_search_is_kits')
# Catalog related fields
product_catalog_product_is_in_bom = fields.Boolean(
compute='_compute_product_is_in_bom_and_mo',
search='_search_product_is_in_bom',
)
product_catalog_product_is_in_mo = fields.Boolean(
compute='_compute_product_is_in_bom_and_mo',
search='_search_product_is_in_mo',
)
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)])
product.bom_count = self.env['mrp.bom'].search_count([
'|', '|', ('byproduct_ids.product_id', 'in', product.ids), ('product_id', 'in', product.ids),
'&', ('product_id', '=', False), ('product_tmpl_id', 'in', product.product_tmpl_id.ids),
])
@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']}
domain = ['&', '&', ('type', '=', 'phantom'),
'|', ('company_id', '=', False),
('company_id', '=', self.env.company.id),
'|', ('product_id', 'in', self.ids),
'&', ('product_id', '=', False),
('product_tmpl_id', 'in', self.product_tmpl_id.ids)]
bom_mapping = self.env['mrp.bom'].sudo().search_read(domain, ['product_tmpl_id', 'product_id'])
kits_template_ids = set([])
kits_product_ids = set([])
for bom_data in bom_mapping:
if bom_data['product_id']:
kits_product_ids.add(bom_data['product_id'][0])
else:
kits_template_ids.add(bom_data['product_tmpl_id'][0])
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'
if operator != 'in':
return NotImplemented
bom_tmpl_query = self.env['mrp.bom'].sudo()._search(
[('company_id', 'in', [False] + self.env.companies.ids),
('active', '=', True),
@ -169,13 +175,10 @@ class ProductProduct(models.Model):
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'))]
return [
'|', ('product_tmpl_id', 'in', bom_tmpl_query.subselect('product_tmpl_id')),
('id', 'in', bom_product_query.subselect('product_id'))
]
def _compute_show_qty_status_button(self):
super()._compute_show_qty_status_button()
@ -186,14 +189,44 @@ class ProductProduct(models.Model):
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)])
product.used_in_bom_count = self.env['mrp.bom'].search_count(
[('bom_line_ids.product_id', 'in', product.ids)])
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']
@api.depends_context('order_id')
def _compute_product_is_in_bom_and_mo(self):
# Just to enable the _search method
self.product_catalog_product_is_in_bom = False
self.product_catalog_product_is_in_mo = False
def _search_product_is_in_bom(self, operator, value):
if operator != 'in':
return NotImplemented
product_ids = self.env['mrp.bom.line'].search([
('bom_id', '=', self.env.context.get('order_id', '')),
]).product_id.ids
return [('id', operator, product_ids)]
def _search_product_is_in_mo(self, operator, value):
if operator != 'in':
return NotImplemented
product_ids = self.env['mrp.production'].search([
('id', 'in', [self.env.context.get('order_id', '')]),
]).move_raw_ids.product_id.ids
return [('id', operator, product_ids)]
def write(self, vals):
if 'active' in vals:
self.filtered(lambda p: p.active != vals['active']).with_context(active_test=False).variant_bom_ids.write({
'active': vals['active']
})
return super().write(values)
return super().write(vals)
def get_total_routes(self):
routes = super().get_total_routes()
if self.bom_ids:
manufacture_routes = self.env['stock.rule'].search([('action', '=', 'manufacture')]).route_id
routes |= manufacture_routes
return routes
def get_components(self):
""" Return the components list ids in case of kit product.
@ -202,7 +235,7 @@ class ProductProduct(models.Model):
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']
return [bom_line.product_id.id for bom_line, data in bom_sub_lines if bom_line.product_id.is_storable]
else:
return super(ProductProduct, self).get_components()
@ -213,16 +246,16 @@ class ProductProduct(models.Model):
return action
def _compute_mrp_product_qty(self):
date_from = fields.Datetime.to_string(fields.datetime.now() - timedelta(days=365))
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])
domain = [('state', '=', 'done'), ('product_id', 'in', self.ids), ('date_start', '>', date_from)]
read_group_res = self.env['mrp.production']._read_group(domain, ['product_id'], ['product_uom_qty:sum'])
mapped_data = {product.id: qty for product, qty 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)
product.mrp_product_qty = product.uom_id.round(mapped_data.get(product.id, 0))
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 :
@ -271,7 +304,7 @@ class ProductProduct(models.Model):
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):
if not component.is_storable or bom_line.product_uom_id.is_zero(bom_line_data['qty']):
# 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.
@ -280,30 +313,29 @@ class ProductProduct(models.Model):
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),
"virtual_available": component.uom_id.round(component.virtual_available),
"qty_available": component.uom_id.round(component.qty_available),
"incoming_qty": component.uom_id.round(component.incoming_qty),
"outgoing_qty": component.uom_id.round(component.outgoing_qty),
"free_qty": component.uom_id.round(component.free_qty),
}
)
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'))
ratios_virtual_available.append(component.uom_id.round(component_res["virtual_available"] / qty_per_kit, rounding_method='DOWN'))
ratios_qty_available.append(component.uom_id.round(component_res["qty_available"] / qty_per_kit, rounding_method='DOWN'))
ratios_incoming_qty.append(component.uom_id.round(component_res["incoming_qty"] / qty_per_kit, rounding_method='DOWN'))
ratios_outgoing_qty.append(component.uom_id.round(component_res["outgoing_qty"] / qty_per_kit, rounding_method='DOWN'))
ratios_free_qty.append(component.uom_id.round(component_res["free_qty"] / qty_per_kit, 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,
'virtual_available': component.uom_id.round(min(ratios_virtual_available) * bom_kits[product].product_qty) // 1,
'qty_available': component.uom_id.round(min(ratios_qty_available) * bom_kits[product].product_qty) // 1,
'incoming_qty': component.uom_id.round(min(ratios_incoming_qty) * bom_kits[product].product_qty) // 1,
'outgoing_qty': component.uom_id.round(min(ratios_outgoing_qty) * bom_kits[product].product_qty) // 1,
'free_qty': component.uom_id.round(min(ratios_free_qty) * bom_kits[product].product_qty) // 1,
}
else:
res[product.id] = {
@ -340,28 +372,9 @@ class ProductProduct(models.Model):
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`).
@ -376,19 +389,19 @@ class ProductProduct(models.Model):
# * 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'),
def _count_returned_sn_products_domain(self, sn_lot, or_domains):
or_domains.append([
('production_id', '=', False),
('location_id.usage', '=', 'production'),
('move_id.unbuild_id', '!=', False),
])
return super()._count_returned_sn_products(sn_lot) + res
return super()._count_returned_sn_products_domain(sn_lot, or_domains)
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'''
op = PY_OPERATORS.get(operator)
if not op:
return NotImplemented
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']
@ -398,8 +411,10 @@ class ProductProduct(models.Model):
else:
kit_products |= kit.product_tmpl_id.product_variant_ids
for product in kit_products:
if OPERATORS[operator](product.qty_available, value):
if op(product.qty_available, value):
product_ids.append(product.id)
elif product.id in product_ids:
product_ids.pop(product_ids.index(product.id))
return list(set(product_ids))
def action_archive(self):
@ -418,3 +433,45 @@ class ProductProduct(models.Model):
},
}
return res
def _get_backend_root_menu_ids(self):
return super()._get_backend_root_menu_ids() + [self.env.ref('mrp.menu_mrp_root').id]
def _update_uom(self, to_uom_id):
for uom, product_template, boms in self.env['mrp.bom']._read_group(
[('product_tmpl_id', 'in', self.product_tmpl_id.ids)],
['product_uom_id', 'product_tmpl_id'],
['id:recordset'],
):
if product_template.uom_id != uom:
raise UserError(_('As other units of measure (ex : %(problem_uom)s) '
'than %(uom)s have already been used for this product, the change of unit of measure can not be done.'
'If you want to change it, please archive the product and create a new one.',
problem_uom=uom.name, uom=product_template.uom_id.name))
boms.product_uom_id = to_uom_id
for uom, product, bom_lines in self.env['mrp.bom.line']._read_group(
[('product_id', 'in', self.ids)],
['product_uom_id', 'product_id'],
['id:recordset'],
):
if product.product_tmpl_id.uom_id != uom:
raise UserError(_('As other units of measure (ex : %(problem_uom)s) '
'than %(uom)s have already been used for this product, the change of unit of measure can not be done.'
'If you want to change it, please archive the product and create a new one.',
problem_uom=uom.name, uom=product.product_tmpl_id.uom_id.name))
bom_lines.product_uom_id = to_uom_id
for uom, product, productions in self.env['mrp.production']._read_group(
[('product_id', 'in', self.ids)],
['product_uom_id', 'product_id'],
['id:recordset'],
):
if product.product_tmpl_id.uom_id != uom:
raise UserError(_('As other units of measure (ex : %(problem_uom)s) '
'than %(uom)s have already been used for this product, the change of unit of measure can not be done.'
'If you want to change it, please archive the product and create a new one.',
problem_uom=uom.name, uom=product.product_tmpl_id.uom_id.name))
productions.product_uom_id = to_uom_id
return super()._update_uom(to_uom_id)

View file

@ -0,0 +1,23 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ProductDocument(models.Model):
_inherit = 'product.document'
def _default_attached_on_mrp(self):
return "bom" if self.env.context.get('attached_on_bom') else "hidden"
attached_on_mrp = fields.Selection(
selection=[
('hidden', "Hidden"),
('bom', "Bill of Materials")
],
required=True,
string="MRP : Visible at",
help="Leave hidden if document only accessible on product form.\n"
"Select Bill of Materials to visualise this document as a product attachment when this product is in a bill of material.",
default=lambda self: self._default_attached_on_mrp(),
)

View file

@ -4,13 +4,9 @@
from odoo import api, fields, models
class Company(models.Model):
class ResCompany(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:
@ -34,10 +30,5 @@ class Company(models.Model):
company_todo_sequence._create_unbuild_sequence()
def _create_per_company_sequences(self):
super(Company, self)._create_per_company_sequences()
super()._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

@ -7,13 +7,10 @@ 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")
@ -38,24 +35,6 @@ class ResConfigSettings(models.TransientModel):
# 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. """

View file

@ -12,6 +12,8 @@ class StockLot(models.Model):
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:
component_product_ids = set(active_mo.move_raw_ids.product_id.ids)
product_ids = self.env.context.get('lot_product_ids')
if not active_mo.picking_type_id.use_create_components_lots and product_ids & component_product_ids:
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

@ -1,192 +1,132 @@
# -*- 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
from odoo import _, api, Command, fields, models
from odoo.tools import OrderedSet, float_is_zero
from odoo.exceptions import ValidationError
class StockMove(models.Model):
_inherit = 'stock.move'
@api.model
def default_get(self, fields):
defaults = super().default_get(fields)
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['reference_ids'] = production_id.reference_ids.ids
defaults['reference'] = production_id.name
return defaults
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')
'mrp.production', 'Production Order for finished products', check_company=True, index='btree_not_null', ondelete="cascade")
raw_material_production_id = fields.Many2one(
'mrp.production', 'Production Order for components', check_company=True, index='btree_not_null')
'mrp.production', 'Production Order for components', check_company=True, index='btree_not_null', ondelete="cascade")
production_group_id = fields.Many2one(
'mrp.production.group', 'Used for Productions')
unbuild_id = fields.Many2one(
'mrp.unbuild', 'Disassembly Order', check_company=True)
'mrp.unbuild', 'Disassembly Order', check_company=True, index='btree_not_null')
consume_unbuild_id = fields.Many2one(
'mrp.unbuild', 'Consumed Disassembly Order', check_company=True)
'mrp.unbuild', 'Consumed Disassembly Order', check_company=True, index='btree_not_null')
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)
'mrp.workorder', 'Work Order To Consume', copy=False, check_company=True, index='btree_not_null')
# 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')
order_finished_lot_ids = fields.Many2many('stock.lot', string="Finished Lot/Serial Number", related="raw_material_production_id.lot_producing_ids")
should_consume_qty = fields.Float('Quantity To Consume', compute='_compute_should_consume_qty', digits='Product Unit')
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,
'Manual Consumption', compute='_compute_manual_consumption', store=True, readonly=False,
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')
@api.depends('product_id.bom_ids', 'product_id.bom_ids.product_uom_id')
def _compute_allowed_uom_ids(self):
super()._compute_allowed_uom_ids()
for move in self:
move.allowed_uom_ids |= move.product_id.bom_ids.product_uom_id
@api.depends('production_id')
def _compute_packaging_uom_id(self):
super()._compute_packaging_uom_id()
for move in self:
if move.production_id:
move.packaging_uom_id = move.production_id.product_uom_id
@api.depends('product_id')
def _compute_manual_consumption(self):
for move in self:
if move.state != 'draft':
continue
move.manual_consumption = move._is_manual_consumption()
# when computed for new_id in onchange, use value from _origin
if move != move._origin:
move.manual_consumption = move._origin.manual_consumption
elif not move.manual_consumption:
move.manual_consumption = move._is_manual_consumption()
@api.depends('raw_material_production_id.location_src_id', 'production_id.location_src_id')
def _compute_location_id(self):
ids_to_super = set()
for move in self:
if move.production_id:
move.location_id = move.product_id.with_company(move.company_id).property_stock_production.id
elif move.raw_material_production_id:
move.location_id = move.raw_material_production_id.location_src_id
else:
ids_to_super.add(move.id)
return super(StockMove, self.browse(ids_to_super))._compute_location_id()
@api.depends('raw_material_production_id.location_dest_id', 'production_id.location_dest_id')
def _compute_location_dest_id(self):
ids_to_super = set()
for move in self:
if move.production_id:
move.location_dest_id = move.production_id.location_dest_id
elif move.raw_material_production_id:
move.location_dest_id = move.product_id.with_company(move.company_id).property_stock_production.id
else:
ids_to_super.add(move.id)
return super(StockMove, self.browse(ids_to_super))._compute_location_dest_id()
@api.depends('bom_line_id')
def _compute_description_bom_line(self):
def _compute_description_picking(self):
super()._compute_description_picking()
bom_line_description = {}
for bom in self.bom_line_id.bom_id:
if bom.type != 'phantom':
continue
line_ids = bom.bom_line_ids.ids
# mapped('id') to keep NewId
line_ids = self.bom_line_id.filtered(lambda line: line.bom_id == bom).mapped('id')
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)
bom_line_description[line_id] = '%s - %d/%d' % (bom.display_name, i + 1, total)
for move in self:
move.description_bom_line = bom_line_description.get(move.bom_line_id.id)
if not move.description_picking_manual and move.bom_line_id.id in bom_line_description:
if move.description_picking == move.product_id.display_name:
move.description_picking = ''
move.description_picking += ('\n' if move.description_picking else '') + bom_line_description.get(move.bom_line_id.id)
@api.depends('raw_material_production_id.priority')
def _compute_priority(self):
@ -210,11 +150,6 @@ class StockMove(models.Model):
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')
@ -226,7 +161,7 @@ class StockMove(models.Model):
else:
move.unit_factor = 1.0
@api.depends('raw_material_production_id', 'raw_material_production_id.name', 'production_id', 'production_id.name')
@api.depends('raw_material_production_id', 'raw_material_production_id.name', 'production_id', 'production_id.name', 'unbuild_id', 'unbuild_id.name')
def _compute_reference(self):
moves_with_reference = self.env['stock.move']
for move in self:
@ -236,8 +171,20 @@ class StockMove(models.Model):
if move.production_id and move.production_id.name:
move.reference = move.production_id.name
moves_with_reference |= move
if move.unbuild_id and move.unbuild_id.name:
move.reference = move.unbuild_id.name
moves_with_reference |= move
super(StockMove, self - moves_with_reference)._compute_reference()
def _set_references(self):
super()._set_references()
for move in self:
if move.reference_ids:
continue
production = move.raw_material_production_id or move.production_id
if production:
move.reference_ids = [Command.set(production.reference_ids.ids)]
@api.depends('raw_material_production_id.qty_producing', 'product_uom_qty', 'product_uom')
def _compute_should_consume_qty(self):
for move in self:
@ -245,30 +192,45 @@ class StockMove(models.Model):
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)
move.should_consume_qty = move.product_uom.round((mo.qty_producing - mo.qty_produced) * move.unit_factor)
@api.onchange('product_uom_qty')
@api.depends('byproduct_id')
def _compute_show_info(self):
super()._compute_show_info()
byproduct_moves = self.filtered(lambda m: m.byproduct_id or m in self.production_id.move_finished_ids)
byproduct_moves.show_quant = False
byproduct_moves.show_lots_m2o = True
@api.depends('picking_type_id.use_create_components_lots')
def _compute_display_assign_serial(self):
super()._compute_display_assign_serial()
for move in self:
if move.display_import_lot \
and move.raw_material_production_id \
and not move.raw_material_production_id.picking_type_id.use_create_components_lots:
move.display_import_lot = False
move.display_assign_serial = False
@api.onchange('product_uom_qty', 'product_uom')
def _onchange_product_uom_qty(self):
if self.raw_material_production_id and self.has_tracking == 'none':
if self.product_uom and self.raw_material_production_id and self.has_tracking == 'none'\
and self.state not in ('draft', 'cancel', 'done'):
mo = self.raw_material_production_id
self._update_quantity_done(mo)
new_qty = self.product_uom.round((mo.qty_producing - mo.qty_produced) * self.unit_factor)
self.quantity = new_qty
@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.onchange('quantity', 'product_uom', 'picked')
def _onchange_quantity(self):
if self.raw_material_production_id and self.product_uom and \
not float_is_zero(self.quantity, precision_rounding=self.product_uom.rounding) and self.product_uom.compare(self.product_uom_qty, self.quantity) != 0:
self.manual_consumption = True
self.picked = True
@api.constrains('quantity', 'raw_material_production_id')
def _check_negative_quantity(self):
for move in self:
if move.raw_material_production_id and move.product_uom.compare(move.quantity, 0) < 0:
raise ValidationError(_("Please enter a positive quantity."))
@api.model_create_multi
def create(self, vals_list):
@ -279,62 +241,103 @@ class StockMove(models.Model):
"""
if self.env.context.get('force_manual_consumption'):
for vals in vals_list:
vals['manual_consumption'] = True
if 'quantity' in vals:
vals['manual_consumption'] = True
vals['picked'] = 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:
if mo_id and location_dest.usage != 'inventory':
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
values['reference_ids'] = mo.reference_ids.ids
values['production_group_id'] = mo.production_group_id.id
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
if mo.state in ['progress', 'to_close'] and mo.qty_producing > 0:
values['picked'] = True
continue
# produced products + byproducts
values['location_id'] = mo.production_location_id.id
values['date'] = mo._get_date_planned_finished()
values['date'] = mo.date_finished
values['date_deadline'] = mo.date_deadline
if not values.get('location_dest_id'):
values['location_dest_id'] = mo.location_dest_id.id
if not values.get('location_final_id'):
values['location_final_id'] = mo.warehouse_id.lot_stock_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
if 'product_id' in vals:
move_to_unlink = self.filtered(lambda m: m.product_id.id != vals.get('product_id'))
other_move = self - move_to_unlink
if move_to_unlink.production_id and move_to_unlink.state not in ['draft', 'cancel', 'done']:
moves_data = move_to_unlink.copy_data()
for move_data in moves_data:
move_data.update({'product_id': vals.get('product_id')})
updated_product_move = self.create(moves_data)
updated_product_move._action_confirm()
move_to_unlink.unlink()
self = other_move + updated_product_move
if self.env.context.get('force_manual_consumption') and 'quantity' in vals:
moves_to_update = self.filtered(lambda move: move.product_uom_qty != vals['quantity'])
if moves_to_update:
moves_to_update.write({'manual_consumption': True, 'picked': True})
if 'product_uom_qty' in vals and '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})
old_demand = {move.id: move.product_uom_qty for move in self}
res = super().write(vals)
if 'product_uom_qty' in vals and not self.env.context.get('no_procurement', False):
# when updating consumed qty need to update related pickings
# context no_procurement means we don't want the qty update to modify stock i.e create new pickings
# ex. when spliting MO to backorders we don't want to move qty from pre prod to stock in 2/3 step config
self.filtered(lambda m: m.raw_material_production_id.state in ('confirmed', 'progress', 'to_close'))._run_procurement(old_demand)
return res
def _run_procurement(self, old_qties=False):
procurements = []
old_qties = old_qties or {}
to_assign = self.env['stock.move']
self._adjust_procure_method()
for move in self:
if move.product_uom.compare(move.product_uom_qty - old_qties.get(move.id, 0), 0) < 0\
and move.procure_method == 'make_to_order'\
and move.move_orig_ids and all(m.state == 'done' for m in move.move_orig_ids):
continue
if move.product_uom.compare(move.product_uom_qty, 0) > 0:
if move._should_bypass_reservation() \
or move.picking_type_id.reservation_method == 'at_confirm' \
or (move.reservation_date and move.reservation_date <= fields.Date.today()):
to_assign |= move
if move.procure_method == 'make_to_order' or move.rule_id.procure_method == 'mts_else_mto':
procurement_qty = move.product_uom_qty - old_qties.get(move.id, 0)
if move.move_orig_ids:
possible_reduceable_qty = -sum(move.move_orig_ids.filtered(lambda m: m.state not in ('done', 'cancel') and m.product_uom_qty).mapped('product_uom_qty'))
procurement_qty = max(procurement_qty, possible_reduceable_qty)
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)
procurements.append(self.env['stock.rule'].Procurement(
move.product_id, procurement_qty, move.product_uom,
move.location_id, move.reference, move.origin, move.company_id, values))
to_assign._action_assign()
if procurements:
self.env['stock.rule'].run(procurements)
def _action_assign(self, force_qty=False):
res = super(StockMove, self)._action_assign(force_qty=force_qty)
@ -344,11 +347,21 @@ class StockMove(models.Model):
'workorder_id': move.workorder_id.id,})
return res
def _action_confirm(self, merge=True, merge_into=False):
def _action_confirm(self, merge=True, merge_into=False, create_proc=True):
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)
return super(StockMove, moves)._action_confirm(merge=merge, merge_into=merge_into, create_proc=create_proc)
def _action_done(self, cancel_backorder=False):
# explode kit moves that avoided the action_explode of any confirmation process
moves_to_explode = self.filtered(lambda m: m.product_id.is_kits and m.state not in ('draft', 'cancel'))
exploded_moves = moves_to_explode.action_explode()
moves = (self - moves_to_explode) | exploded_moves
return super(StockMove, moves)._action_done(cancel_backorder)
def _should_bypass_reservation(self, forced_location=False):
return super()._should_bypass_reservation(forced_location) or self.product_id.with_company(self.company_id).is_kits
def action_explode(self):
""" Explodes pickings """
@ -359,23 +372,19 @@ class StockMove(models.Model):
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):
if (not move.picking_type_id and not (self.env.context.get('is_scrap') or self.env.context.get('skip_picking_assignation'))) 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
if move.product_uom.is_zero(move.product_uom_qty):
factor = move.product_uom._compute_quantity(move.quantity, 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)
_dummy, lines = bom.sudo().explode(move.product_id, factor, picking_type=bom.picking_type_id, never_attribute_values=move.never_product_template_attribute_value_ids)
phantom_moves_vals_list += move._generate_all_phantom_moves(lines)
# delete the move with original product which is not relevant anymore
moves_ids_to_unlink.add(move.id)
@ -384,7 +393,7 @@ class StockMove(models.Model):
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.quantity = 0
move_to_unlink._action_cancel()
move_to_unlink.unlink()
return self.env['stock.move'].browse(moves_ids_to_return)
@ -393,21 +402,32 @@ class StockMove(models.Model):
self.ensure_one()
action = super().action_show_details()
if self.raw_material_production_id:
action['name'] = _("Components")
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['name'] = _("Move Byproduct")
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_add_from_catalog_raw(self):
mo = self.env['mrp.production'].browse(self.env.context.get('order_id'))
return mo.with_context(child_field='move_raw_ids').action_add_from_catalog()
def action_add_from_catalog_byproduct(self):
mo = self.env['mrp.production'].browse(self.env.context.get('order_id'))
return mo.with_context(child_field='move_byproduct_ids').action_add_from_catalog()
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()
if not 'skip_mo_check' in self.env.context:
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):
@ -427,15 +447,28 @@ class StockMove(models.Model):
'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,
'quantity': quantity_done,
'picked': self.picked,
'bom_line_id': bom_line.id,
'description_picking': self.product_id.display_name,
}
def _generate_all_phantom_moves(self, exploded_lines_data):
self.ensure_one()
phantom_moves_vals_list = []
for bom_line, line_data in exploded_lines_data:
if self.product_uom.is_zero(self.product_uom_qty) or self.env.context.get('is_scrap'):
vals = self._generate_move_phantom(bom_line, 0, line_data['qty'])
else:
vals = self._generate_move_phantom(bom_line, line_data['qty'], 0)
for val in vals:
val['cost_share'] = line_data.get('line_cost_share', 0.0)
phantom_moves_vals_list += vals
return phantom_moves_vals_list
def _generate_move_phantom(self, bom_line, product_qty, quantity_done):
vals = []
if bom_line.product_id.type in ['product', 'consu']:
if bom_line.product_id.type == '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:
@ -480,16 +513,15 @@ class StockMove(models.Model):
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
return self.product_uom.is_zero(self.product_uom_qty)
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 _prepare_move_line_vals(self, quantity=None, reserved_quant=None):
vals = super()._prepare_move_line_vals(quantity, reserved_quant)
if self.raw_material_production_id:
vals['production_id'] = self.raw_material_production_id.id
if self.production_id.product_tracking == 'lot' and self.product_id == self.production_id.product_id and self.production_id.lot_producing_ids:
vals['lot_id'] = self.production_id.lot_producing_ids.ids[0]
return vals
def _key_assign_picking(self):
keys = super(StockMove, self)._key_assign_picking()
@ -498,7 +530,7 @@ class StockMove(models.Model):
@api.model
def _prepare_merge_moves_distinct_fields(self):
res = super()._prepare_merge_moves_distinct_fields()
res += ['created_production_id', 'cost_share']
res += ['created_production_id', 'cost_share', 'production_group_id']
if self.bom_line_id and ("phantom" in self.bom_line_id.bom_id.mapped('type')):
res.append('bom_line_id')
return res
@ -518,12 +550,20 @@ class StockMove(models.Model):
:return: The quantity delivered or received
"""
qty_ratios = []
kit_qty = kit_qty / kit_bom.product_qty
boms, bom_sub_lines = kit_bom.explode(product_id, kit_qty)
def get_qty(move):
if move.picked:
return move.product_uom._compute_quantity(move.quantity, move.product_id.uom_id, rounding_method='HALF-UP')
else:
return move.product_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):
if bom_line.product_uom_id.is_zero(bom_line_data['qty']):
# As BoMs allow components with 0 qty, a.k.a. optionnal components, we simply skip those
# to avoid a division by zero.
continue
@ -532,15 +572,20 @@ class StockMove(models.Model):
# 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)
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 / kit_bom.product_qty, bom_line.product_id.uom_id, round=False)
if not qty_per_kit:
continue
# Due to multi-step only the last move of each chain should be considered
incoming_moves = bom_line_moves.filtered(filters['incoming_moves'])
final_incoming_moves = incoming_moves - incoming_moves.move_orig_ids
incoming_qty = sum(final_incoming_moves.mapped(get_qty))
outgoing_moves = bom_line_moves.filtered(filters['outgoing_moves'])
qty_processed = sum(incoming_moves.mapped('product_qty')) - sum(outgoing_moves.mapped('product_qty'))
final_outgoing_moves = outgoing_moves - outgoing_moves.move_orig_ids
outgoing_qty = sum(final_outgoing_moves.mapped(get_qty))
qty_processed = incoming_qty - outgoing_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))
qty_ratios.append(bom_line.product_id.uom_id.round(qty_processed / qty_per_kit))
else:
return 0.0
if qty_ratios:
@ -551,48 +596,31 @@ class StockMove(models.Model):
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)
def _update_candidate_moves_list(self, candidate_moves_set):
super()._update_candidate_moves_list(candidate_moves_set)
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))
candidate_moves_set.add(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)
candidate_moves_set.add(production.move_finished_ids.filtered(lambda m: m.product_id in self.product_id))
# this will include sibling pickings as a result of merging MOs
for picking in self.move_dest_ids.raw_material_production_id.picking_ids:
candidate_moves_set.add(picking.move_ids)
def _prepare_procurement_values(self):
res = super()._prepare_procurement_values()
res['production_group_id'] = self.production_group_id.id
res['bom_line_id'] = self.bom_line_id.id
return res
def _search_picking_for_assignation_domain(self):
domain = super()._search_picking_for_assignation_domain()
domain += [('production_group_id', '=', self.production_group_id.id)]
return domain
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):
if source and source.browse().has_access('read'):
return {
'res_model': source._name,
'type': 'ir.actions.act_window',
@ -603,9 +631,18 @@ class StockMove(models.Model):
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)
return self._determine_is_manual_consumption(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)
def _determine_is_manual_consumption(self, bom_line):
return bom_line and bom_line.operation_id
def _get_relevant_state_among_moves(self):
res = super()._get_relevant_state_among_moves()
if res == 'partially_available'\
and self.raw_material_production_id\
and all(move.should_consume_qty and move.product_uom.compare(move.quantity, move.should_consume_qty) >= 0
or (move.product_uom.compare(move.quantity, move.product_uom_qty) >= 0 or (move.manual_consumption and move.picked))
for move in self):
res = 'assigned'
return res

View file

@ -0,0 +1,132 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo.fields import Domain
class StockMoveLine(models.Model):
_inherit = 'stock.move.line'
workorder_id = fields.Many2one('mrp.workorder', 'Work Order', check_company=True, index='btree_not_null')
production_id = fields.Many2one('mrp.production', 'Production Order', check_company=True)
@api.depends('production_id')
def _compute_picking_type_id(self):
line_to_remove = self.env['stock.move.line']
for line in self:
if production_id := line.production_id or line.move_id.production_id:
line.picking_type_id = 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):
if operator in Domain.NEGATIVE_OPERATORS:
return NotImplemented
domain = super()._search_picking_type_id(operator, value)
return (Domain('production_id', '=', False) & domain) | Domain('production_id.picking_type_id', operator, value)
@api.model_create_multi
def create(self, vals_list):
res = super().create(vals_list)
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_ids
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()._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 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', 'quantity')):
move_line._log_message(production, move_line, 'mrp.track_production_move_template', vals)
return super().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
def _prepare_stock_move_vals(self):
move_vals = super()._prepare_stock_move_vals()
if self.env['product.product'].browse(move_vals['product_id']).is_kits:
move_vals['location_id'] = self.location_id.id
move_vals['location_dest_id'] = self.location_dest_id.id
return move_vals
def _get_linkable_moves(self):
""" Don't linke move lines with kit products to moves with dissimilar locations so that
post `action_explode()` move lines will have accurate location data.
"""
self.ensure_one()
if self.product_id and self.product_id.is_kits:
moves = self.picking_id.move_ids.filtered(lambda move:
move.product_id == self.product_id and
move.location_id == self.location_id and
move.location_dest_id == self.location_dest_id
)
return sorted(moves, key=lambda m: m.quantity < m.product_qty, reverse=True)
else:
return super()._get_linkable_moves()
def _exclude_requiring_lot(self):
return (
self.move_id.unbuild_id
and not self.move_id.origin_returned_move_id.move_line_ids.lot_id
) or super()._exclude_requiring_lot()

View file

@ -1,10 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, time
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
from odoo.fields import Domain
class StockWarehouseOrderpoint(models.Model):
@ -13,17 +12,28 @@ class StockWarehouseOrderpoint(models.Model):
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.")
domain="[('type', '=', 'normal'), '&', '|', ('company_id', '=', company_id), ('company_id', '=', False), '|', ('product_id', '=', product_id), '&', ('product_id', '=', False), ('product_tmpl_id', '=', product_tmpl_id)]",
inverse='_inverse_bom_id',
)
bom_id_placeholder = fields.Char(compute='_compute_bom_id_placeholder')
effective_bom_id = fields.Many2one(
'mrp.bom', string='Effective Bill of Materials', search='_search_effective_bom_id', compute='_compute_effective_bom_id',
store=False, help='Either the Bill of Materials set directly or the one computed to be used by this replenishment'
)
def _inverse_route_id(self):
for orderpoint in self:
if not orderpoint.route_id:
orderpoint.bom_id = False
super()._inverse_route_id()
def _get_replenishment_order_notification(self):
self.ensure_one()
domain = [('orderpoint_id', 'in', self.ids)]
domain = Domain('orderpoint_id', 'in', self.ids)
if self.env.context.get('written_after'):
domain = AND([domain, [('write_date', '>', self.env.context.get('written_after'))]])
domain &= 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',
@ -32,7 +42,7 @@ class StockWarehouseOrderpoint(models.Model):
'message': '%s',
'links': [{
'label': production.name,
'url': f'#action={action.id}&id={production.id}&model=mrp.production'
'url': f'/odoo/action-mrp.action_mrp_production_form/{production.id}'
}],
'sticky': False,
'next': {'type': 'ir.actions.act_window_close'},
@ -40,35 +50,105 @@ class StockWarehouseOrderpoint(models.Model):
}
return super()._get_replenishment_order_notification()
@api.depends('route_id')
@api.depends('bom_id', 'product_id.bom_ids.produce_delay')
def _compute_deadline_date(self):
""" Extend to add more depends values """
super()._compute_deadline_date()
def _get_lead_days_values(self):
values = super()._get_lead_days_values()
if self.bom_id:
values['bom'] = self.bom_id
return values
@api.depends('bom_id', 'bom_id.product_uom_id', 'product_id.bom_ids', 'product_id.bom_ids.product_uom_id')
def _compute_qty_to_order_computed(self):
""" Extend to add more depends values """
super()._compute_qty_to_order_computed()
def _compute_allowed_replenishment_uom_ids(self):
super()._compute_allowed_replenishment_uom_ids()
for orderpoint in self:
if 'manufacture' in orderpoint.rule_ids.mapped('action'):
orderpoint.allowed_replenishment_uom_ids += orderpoint.product_id.bom_ids.product_uom_id
def _compute_show_supply_warning(self):
for orderpoint in self:
if 'manufacture' in orderpoint.rule_ids.mapped('action') and not orderpoint.show_supply_warning:
orderpoint.show_supply_warning = not orderpoint.product_id.bom_ids
continue
super(StockWarehouseOrderpoint, orderpoint)._compute_show_supply_warning()
@api.depends('effective_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
orderpoint.show_bom = orderpoint.effective_route_id.id in manufacture_route
def _compute_visibility_days(self):
res = super()._compute_visibility_days()
def _inverse_bom_id(self):
for orderpoint in self:
if 'manufacture' in orderpoint.rule_ids.mapped('action'):
orderpoint.visibility_days = orderpoint.manufacturing_visibility_days
return res
if not orderpoint.route_id and orderpoint.bom_id:
orderpoint.route_id = self.env['stock.rule'].search([('action', '=', 'manufacture')])[0].route_id
def _set_visibility_days(self):
res = super()._set_visibility_days()
@api.depends('effective_route_id', 'bom_id', 'rule_ids', 'product_id.bom_ids')
def _compute_bom_id_placeholder(self):
for orderpoint in self:
if 'manufacture' in orderpoint.rule_ids.mapped('action'):
orderpoint.manufacturing_visibility_days = orderpoint.visibility_days
return res
default_bom = orderpoint._get_default_bom()
orderpoint.bom_id_placeholder = default_bom.display_name if default_bom else ''
@api.depends('effective_route_id', 'bom_id', 'rule_ids', 'product_id.bom_ids')
def _compute_effective_bom_id(self):
for orderpoint in self:
orderpoint.effective_bom_id = orderpoint.bom_id if orderpoint.bom_id else orderpoint._get_default_bom()
def _search_effective_bom_id(self, operator, value):
boms = self.env['mrp.bom'].search([('id', operator, value)])
orderpoints = self.env['stock.warehouse.orderpoint'].search([]).filtered(
lambda orderpoint: orderpoint.effective_bom_id in boms
)
return [('id', 'in', orderpoints.ids)]
def _compute_days_to_order(self):
res = super()._compute_days_to_order()
for orderpoint in self:
# Avoid computing rule_ids in case no manufacture rules.
if not self.env['stock.rule'].search([('action', '=', 'manufacture')]):
return res
# Compute rule_ids only for orderpoint with boms
orderpoints_with_bom = self.filtered(lambda orderpoint: orderpoint.product_id.variant_bom_ids or orderpoint.product_id.bom_ids)
for orderpoint in orderpoints_with_bom:
if 'manufacture' in orderpoint.rule_ids.mapped('action'):
orderpoint.days_to_order = orderpoint.product_id.days_to_prepare_mo
boms = orderpoint.bom_id or orderpoint.product_id.variant_bom_ids or orderpoint.product_id.bom_ids
orderpoint.days_to_order = boms and boms[0].days_to_prepare_mo or 0
return res
def _get_default_route(self):
route_ids = self.env['stock.rule'].search([
('action', '=', 'manufacture')
]).route_id
route_id = self.rule_ids.route_id & route_ids
if self.product_id.bom_ids and route_id:
return route_id[0]
return super()._get_default_route()
def _get_default_bom(self):
self.ensure_one()
if self.show_bom:
return self._get_default_rule()._get_matching_bom(
self.product_id, self.company_id, {}
)
else:
return self.env['mrp.bom']
def _get_replenishment_multiple_alternative(self, qty_to_order):
self.ensure_one()
routes = self.effective_route_id or self.product_id.route_ids
if not any(r.action == 'manufacture' for r in routes.rule_ids):
return super()._get_replenishment_multiple_alternative(qty_to_order)
bom = self.bom_id or self.env['mrp.bom']._bom_find(self.product_id, picking_type=False, bom_type='normal', company_id=self.company_id.id)[self.product_id]
return bom.product_uom_id
def _quantity_in_progress(self):
bom_kits = self.env['mrp.bom']._bom_find(self.product_id, bom_type='phantom')
bom_kit_orderpoints = {
@ -85,7 +165,7 @@ class StockWarehouseOrderpoint(models.Model):
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):
if not component.is_storable or bom_line.product_uom_id.is_zero(bom_line_data['qty']):
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)
@ -103,42 +183,38 @@ class StockWarehouseOrderpoint(models.Model):
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])
# add quantities coming from draft MOs
productions_group = self.env['mrp.production']._read_group(
[
('bom_id', 'in', bom_manufacture.ids),
('state', '=', 'draft'),
('orderpoint_id', 'in', orderpoints_without_kit.ids),
('id', 'not in', self.env.context.get('ignore_mo_ids', [])),
],
['orderpoint_id', 'product_uom_id'],
['product_qty:sum'])
for orderpoint, uom, product_qty_sum in productions_group:
res[orderpoint.id] += uom._compute_quantity(
p['product_qty'], orderpoint.product_uom, round=False)
product_qty_sum, orderpoint.product_uom, round=False)
# add quantities coming from confirmed MO to be started but not finished
# by the end of the stock forecast
in_progress_productions = self.env['mrp.production'].search([
('bom_id', 'in', bom_manufacture.ids),
('state', '=', 'confirmed'),
('orderpoint_id', 'in', orderpoints_without_kit.ids),
('id', 'not in', self.env.context.get('ignore_mo_ids', [])),
])
for prod in in_progress_productions:
date_start, date_finished, orderpoint = prod.date_start, prod.date_finished, prod.orderpoint_id
lead_horizon_date = datetime.combine(orderpoint.lead_horizon_date, time.max)
if date_start <= lead_horizon_date < date_finished:
res[orderpoint.id] += prod.product_uom_id._compute_quantity(
prod.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)
def _prepare_procurement_values(self, date=False):
values = super()._prepare_procurement_values(date=date)
values['bom_id'] = self.bom_id
return values

View file

@ -1,7 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from ast import literal_eval
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.fields import Domain
class StockPickingType(models.Model):
@ -9,47 +12,90 @@ class StockPickingType(models.Model):
code = fields.Selection(selection_add=[
('mrp_operation', 'Manufacturing')
], ondelete={'mrp_operation': 'cascade'})
], ondelete={'mrp_operation': lambda recs: recs.write({'code': 'incoming', 'active': False})})
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')
count_mo_in_progress = fields.Integer(string="Number of Manufacturing Orders In Progress",
compute='_get_mo_count')
count_mo_to_close = fields.Integer(string="Number of Manufacturing Orders To Close",
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",
)
auto_print_done_production_order = fields.Boolean(
"Auto Print Done Production Order",
help="If this checkbox is ticked, Odoo will automatically print the production order of a MO when it is done.")
auto_print_done_mrp_product_labels = fields.Boolean(
"Auto Print Produced Product Labels",
help="If this checkbox is ticked, Odoo will automatically print the product labels of a MO when it is done.")
mrp_product_label_to_print = fields.Selection(
[('pdf', 'PDF'), ('zpl', 'ZPL')],
"Product Label to Print", default='pdf')
auto_print_done_mrp_lot = fields.Boolean(
"Auto Print Produced Lot Label",
help="If this checkbox is ticked, Odoo will automatically print the lot/SN label of a MO when it is done.")
done_mrp_lot_label_to_print = fields.Selection(
[('pdf', 'PDF'), ('zpl', 'ZPL')],
"Lot/SN Label to Print", default='pdf')
auto_print_mrp_reception_report = fields.Boolean(
"Auto Print Allocation Report",
help="If this checkbox is ticked, Odoo will automatically print the allocation report of a MO when it is done and has assigned moves.")
auto_print_mrp_reception_report_labels = fields.Boolean(
"Auto Print Allocation Report Labels",
help="If this checkbox is ticked, Odoo will automatically print the allocation report labels of a MO when it is done.")
auto_print_generated_mrp_lot = fields.Boolean(
"Auto Print Generated Lot/SN Label",
help='Automatically print the lot/SN label when the "Create a new serial/lot number" button is used.')
generated_mrp_lot_label_to_print = fields.Selection(
[('pdf', 'PDF'), ('zpl', 'ZPL')],
"Generated Lot/SN Label to Print", default='pdf')
@api.depends('code')
def _compute_use_create_lots(self):
super()._compute_use_create_lots()
for picking_type in self:
if picking_type.code == 'mrp_operation':
picking_type.use_create_lots = True
@api.depends('code')
def _compute_use_existing_lots(self):
super()._compute_use_existing_lots()
for picking_type in self:
if picking_type.code == 'mrp_operation':
picking_type.use_existing_lots = True
@api.constrains('default_location_dest_id')
def _check_default_location(self):
for record in self:
if record.code == 'mrp_operation' and record.default_location_dest_id.usage == 'inventory':
raise ValidationError(_("You cannot set a scrap location as the destination location for a manufacturing type operation."))
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
remaining = (self - mrp_picking_types)
remaining.count_mo_waiting = remaining.count_mo_todo = remaining.count_mo_late = False
remaining.count_mo_in_progress = remaining.count_mo_to_close = False
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')],
'count_mo_todo': [('state', '=', 'confirmed')],
'count_mo_late': [('date_start', '<', fields.Date.today()), ('state', '=', 'confirmed')],
'count_mo_in_progress': [('state', '=', 'progress')],
'count_mo_to_close': [('state', '=', 'to_close')],
}
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 key, domain in domains.items():
data = self.env['mrp.production']._read_group(domain +
[('state', 'not in', ('done', 'cancel')), ('picking_type_id', 'in', mrp_picking_types.ids)],
['picking_type_id'], ['__count'])
count = {picking_type.id: count for picking_type, count 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
record[key] = count.get(record.id, 0)
def get_mrp_stock_picking_action_picking_type(self):
action = self.env["ir.actions.actions"]._for_xml_id('mrp.mrp_production_action_picking_deshboard')
@ -57,22 +103,78 @@ class StockPickingType(models.Model):
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
def _get_aggregated_records_by_date(self):
production_picking_types = self.filtered(lambda picking: picking.code == 'mrp_operation')
other_picking_types = (self - production_picking_types)
records = super(StockPickingType, other_picking_types)._get_aggregated_records_by_date()
mrp_records = self.env['mrp.production']._read_group(
[
('picking_type_id', 'in', production_picking_types.ids),
('state', '=', 'confirmed')
],
['picking_type_id'],
['date_start' + ':array_agg'],
)
# Make sure that all picking type IDs are represented, even if empty
picking_type_id_to_dates = {i: [] for i in production_picking_types.ids}
picking_type_id_to_dates.update({r[0].id: r[1] for r in mrp_records})
mrp_records = [(i, d, self.env._('Confirmed')) for i, d in picking_type_id_to_dates.items()]
return records + mrp_records
class StockPicking(models.Model):
_inherit = 'stock.picking'
has_kits = fields.Boolean(compute='_compute_has_kits')
production_count = fields.Integer(
"Count of MO generated",
compute='_compute_mrp_production_ids',
groups='mrp.group_mrp_user')
production_ids = fields.One2many(
'mrp.production',
related='move_ids.production_group_id.production_ids',
groups='mrp.group_mrp_user')
production_group_id = fields.Many2one(
'mrp.production.group',
string="Production Group",
related='move_ids.production_group_id',
)
@api.depends('move_ids')
def _compute_has_kits(self):
for picking in self:
picking.has_kits = any(picking.move_ids.mapped('bom_line_id'))
@api.depends('production_ids')
def _compute_mrp_production_ids(self):
for picking in self:
# hide subcontracting MO from resupply picking
mo = picking.production_ids.filtered(lambda mo: mo.picking_type_id.active)
picking.production_count = len(mo)
def action_detailed_operations(self):
action = super().action_detailed_operations()
action['context']['has_kits'] = self.has_kits
return action
def action_view_mrp_production(self):
self.ensure_one()
action = {
'name': _("Manufacturing Orders"),
'res_model': 'mrp.production',
'type': 'ir.actions.act_window',
'domain': [('id', 'in', self.production_ids.ids)],
'view_mode': 'list,form',
}
if self.production_count == 1:
action.update({
'view_mode': 'form',
'res_id': self.production_ids.id,
})
return action
def _less_quantities_than_expected_add_documents(self, moves, documents):
documents = super(StockPicking, self)._less_quantities_than_expected_add_documents(moves, documents)
@ -84,3 +186,22 @@ class StockPicking(models.Model):
production_documents = self._log_activity_get_documents(moves, 'move_dest_ids', 'DOWN', _keys_in_groupby)
return {**documents, **production_documents}
@api.model
def get_action_click_graph(self):
picking_type_id = self.env.context["picking_type_id"]
picking_type_code = self.env["stock.picking.type"].browse(picking_type_id).code
if picking_type_code == "mrp_operation":
action = self._get_action("mrp.action_picking_tree_mrp_operation_graph")
action["domain"] = Domain.AND([
literal_eval(action["domain"] or '[]'), [('picking_type_id', '=', picking_type_id)]
])
allowed_company_ids = self.env.context.get("allowed_company_ids", [])
if allowed_company_ids:
action["context"].update({
"default_company_id": allowed_company_ids[0],
})
return action
return super().get_action_click_graph()

View file

@ -1,15 +1,14 @@
from odoo import models, _
from odoo.exceptions import RedirectWarning
from odoo import models, api, _
from odoo.exceptions import UserError
class StockQuant(models.Model):
_inherit = 'stock.quant'
def action_apply_inventory(self):
@api.constrains('product_id')
def _check_kits(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()
raise UserError(_('You should update the components quantity instead of directly updating the quantity of the kit product.'))
def _should_bypass_product(self, product=False, location=False, reserved_quantity=0, lot_id=False, package_id=False, owner_id=False):
return super()._should_bypass_product(product, location, reserved_quantity, lot_id, package_id, owner_id) or (product and product.is_kits)

View file

@ -0,0 +1,9 @@
from odoo import fields, models
class StockReference(models.Model):
_inherit = 'stock.reference'
production_ids = fields.Many2many(
'mrp.production', 'stock_reference_production_rel', 'reference_id',
'production_id', string="Productions")

View file

@ -0,0 +1,18 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class StockReplenishMixin(models.AbstractModel):
_inherit = 'stock.replenish.mixin'
bom_id = fields.Many2one('mrp.bom', string="Bill of Material")
show_bom = fields.Boolean(compute='_compute_show_bom')
@api.depends('route_id')
def _compute_show_bom(self):
for rec in self:
rec.show_bom = rec._get_show_bom(rec.route_id)
def _get_show_bom(self, route):
return any(r.action == 'manufacture' for r in route.rule_ids)

View file

@ -1,13 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from datetime import datetime
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
from odoo.fields import Domain, Command
from odoo.tools import OrderedSet
class StockRule(models.Model):
@ -18,183 +17,25 @@ class StockRule(models.Model):
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)
source, destination, direct_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
})
manufacture_message += _(' <br/><br/> The components will be taken from <b>%s</b>.', source)
if direct_destination and not self.location_dest_from_rule:
manufacture_message += _(' <br/><br/> The manufactured products will be moved towards <b>%(destination)s</b>, <br/> as specified from <b>%(operation)s</b> destination.', destination=direct_destination, operation=operation)
message_dict['manufacture'] = manufacture_message
return message_dict
@api.depends('action')
def _compute_picking_type_code_domain(self):
remaining = self.browse()
super()._compute_picking_type_code_domain()
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()
rule.picking_type_code_domain = rule.picking_type_code_domain or [] + ['mrp_operation']
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')
if not p.move_raw_ids:
return (not p.workorder_ids and (p.orderpoint_id or p.move_dest_ids.procure_method == 'make_to_stock'))
return not p.orderpoint_id
@api.model
def run(self, procurements, raise_user_error=True):
@ -214,22 +55,201 @@ class ProcurementGroup(models.Model):
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)
_dummy, bom_sub_lines = bom_kit.explode(procurement.product_id, qty_to_produce, never_attribute_values=procurement.values.get("never_product_template_attribute_value_ids"))
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(
procurements_without_kit.append(self.env['stock.rule'].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)
return super().run(procurements_without_kit, raise_user_error=raise_user_error)
def _filter_warehouse_routes(self, product, warehouses, route):
if any(rule.action == 'manufacture' for rule in route.rule_ids):
if any(bom.type == 'normal' for bom in product.bom_ids):
return super()._filter_warehouse_routes(product, warehouses, route)
return False
return super()._filter_warehouse_routes(product, warehouses, route)
@api.model
def _run_manufacture(self, procurements):
new_productions_values_by_company = defaultdict(lambda: defaultdict(list))
for procurement, rule in procurements:
if procurement.product_uom.compare(procurement.product_qty, 0) <= 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)
mo = self.env['mrp.production']
if procurement.origin != 'MPS':
domain = rule._make_mo_get_domain(procurement, bom)
mo = self.env['mrp.production'].sudo().search(domain, limit=1)
is_batch_size = bom and bom.enable_batch_size
if not mo or is_batch_size:
procurement_qty = procurement.product_qty
batch_size = bom.product_uom_id._compute_quantity(bom.batch_size, procurement.product_uom) if is_batch_size else procurement_qty
vals = rule._prepare_mo_vals(*procurement, bom)
while procurement.product_uom.compare(procurement_qty, 0) > 0:
new_productions_values_by_company[procurement.company_id.id]['values'].append({
**vals,
'product_qty': procurement.product_uom._compute_quantity(batch_size, bom.product_uom_id) if bom else procurement_qty,
})
new_productions_values_by_company[procurement.company_id.id]['procurements'].append(procurement)
procurement_qty -= batch_size
else:
procurement_product_uom_qty = procurement.product_uom._compute_quantity(procurement.product_qty, procurement.product_id.uom_id)
self.env['change.production.qty'].sudo().with_context(skip_activity=True).create({
'mo_id': mo.id,
'product_qty': mo.product_id.uom_id._compute_quantity((mo.product_uom_qty + procurement_product_uom_qty), mo.product_uom_id),
}).change_prod_qty()
for company_id in new_productions_values_by_company:
productions_vals_list = new_productions_values_by_company[company_id]['values']
# 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_vals_list)
for mo in productions:
if self._should_auto_confirm_procurement_mo(mo):
mo.action_confirm()
productions._post_run_manufacture(new_productions_values_by_company[company_id]['procurements'])
return True
def _get_stock_move_values(self, product_id, product_qty, product_uom, location_id, name, origin, company_id, values):
res = super()._get_stock_move_values(product_id, product_qty, product_uom, location_id, name, origin, company_id, values)
res['production_group_id'] = values.get('production_group_id')
return res
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)]])
domain = super()._get_moves_to_assign_domain(company_id)
return Domain(domain) & Domain('production_id', '=', False)
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
bom = self.env['mrp.bom']._bom_find(product_id, picking_type=self.picking_type_id, bom_type='normal', company_id=company_id.id)[product_id]
if bom:
return bom
return self.env['mrp.bom']._bom_find(product_id, picking_type=False, bom_type='normal', company_id=company_id.id)[product_id]
def _make_mo_get_domain(self, procurement, bom):
domain = (
('bom_id', '=', bom.id),
('product_id', '=', procurement.product_id.id),
('state', 'in', ['draft', 'confirmed']),
('is_planned', '=', False),
('picking_type_id', '=', self.picking_type_id.id),
('company_id', '=', procurement.company_id.id),
('user_id', '=', False),
('reference_ids', '=', procurement.values.get('reference_ids', self.env['stock.reference']).ids),
)
if procurement.values.get('orderpoint_id'):
procurement_date = datetime.combine(
fields.Date.to_date(procurement.values['date_planned']) - relativedelta(days=int(bom.produce_delay)),
datetime.max.time()
)
domain += ('|',
'&', ('state', '=', 'draft'), ('date_deadline', '<=', procurement_date),
'&', ('state', '=', 'confirmed'), ('date_start', '<=', procurement_date))
return domain
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(bom, values)
date_deadline = values.get('date_deadline') or date_planned + relativedelta(days=bom.produce_delay)
picking_type = bom.picking_type_id or self.picking_type_id
mo_values = {
'origin': origin,
'product_id': product_id.id,
'product_description_variants': values.get('product_description_variants'),
'never_product_template_attribute_value_ids': values.get('never_product_template_attribute_value_ids'),
'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': picking_type.default_location_src_id.id,
'location_dest_id': picking_type.default_location_dest_id.id or location_dest_id.id,
'location_final_id': location_dest_id.id,
'bom_id': bom.id,
'date_deadline': date_deadline,
'date_start': date_planned,
'reference_ids': [Command.set(values.get('reference_ids', self.env['stock.reference']).ids)],
'propagate_cancel': self.propagate_cancel,
'orderpoint_id': values.get('orderpoint_id', False) and values.get('orderpoint_id').id,
'picking_type_id': picking_type.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,
}
if self.location_dest_from_rule:
mo_values['location_dest_id'] = self.location_dest_id.id
return mo_values
def _get_date_planned(self, bom_id, values):
format_date_planned = fields.Datetime.from_string(values['date_planned'])
date_planned = format_date_planned - relativedelta(days=bom_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.
"""
delays, 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 delays, delay_description
manufacture_rule.ensure_one()
bom = values.get('bom') or self.env['mrp.bom']._bom_find(product, picking_type=manufacture_rule.picking_type_id, company_id=manufacture_rule.company_id.id)[product]
if not bom:
delays['total_delay'] += 365
delays['no_bom_found_delay'] += 365
if not bypass_delay_description:
delay_description.append((_('No BoM Found'), _('+ %s day(s)', 365)))
manufacture_delay = bom.produce_delay
delays['total_delay'] += manufacture_delay
delays['manufacture_delay'] += manufacture_delay
if not bypass_delay_description:
delay_description.append((_('Production End Date'), manufacture_delay))
delay_description.append((_('Manufacturing Lead Time'), _('+ %d day(s)', manufacture_delay)))
if bom.type == 'normal':
# pre-production rules
warehouse = self.location_dest_id.warehouse_id
for wh in warehouse:
if wh.manufacture_steps != 'mrp_one_step':
wh_manufacture_rules = product._get_rules_from_location(product.property_stock_production, route_ids=wh.pbm_route_id)
extra_delays, extra_delay_description = (wh_manufacture_rules - self).with_context(global_horizon_days=0)._get_lead_days(product, **values)
for key, value in extra_delays.items():
delays[key] += value
delay_description += extra_delay_description
days_to_order = values.get('days_to_order', bom.days_to_prepare_mo)
delays['total_delay'] += days_to_order
if not bypass_delay_description:
delay_description.append((_('Production Start Date'), days_to_order))
delay_description.append((_('Days to Supply Components'), _('+ %d day(s)', days_to_order)))
return delays, delay_description
def _push_prepare_move_copy_values(self, move_to_copy, new_date):
new_move_vals = super()._push_prepare_move_copy_values(move_to_copy, new_date)
new_move_vals['production_group_id'] = move_to_copy.production_group_id.id
new_move_vals['production_id'] = False
return new_move_vals
class StockRoute(models.Model):
_inherit = "stock.route"
def _is_valid_resupply_route_for_product(self, product):
if any(rule.action == 'manufacture' for rule in self.rule_ids):
return any(bom.type == 'normal' for bom in product.bom_ids)
return super()._is_valid_resupply_route_for_product(product)

View file

@ -9,21 +9,36 @@ class StockScrap(models.Model):
production_id = fields.Many2one(
'mrp.production', 'Manufacturing Order',
states={'done': [('readonly', True)]}, check_company=True)
index='btree_not_null',
check_company=True)
workorder_id = fields.Many2one(
'mrp.workorder', 'Work Order',
states={'done': [('readonly', True)]},
index='btree_not_null',
check_company=True) # Not to restrict or prefer quants, but informative
product_is_kit = fields.Boolean(related='product_id.is_kits')
product_template = fields.Many2one(related='product_id.product_tmpl_id')
bom_id = fields.Many2one(
'mrp.bom', 'Kit',
domain="[('type', '=', 'phantom'), '|', ('product_id', '=', product_id), '&', ('product_id', '=', False), ('product_tmpl_id', '=', product_template)]",
check_company=True)
@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.depends('workorder_id', 'production_id')
def _compute_location_id(self):
remaining_scrap = self.browse()
@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
for scrap in self:
if scrap.production_id:
if scrap.production_id.state != 'done':
scrap.location_id = scrap.production_id.location_src_id.id
else:
scrap.location_id = scrap.production_id.location_dest_id.id
elif scrap.workorder_id:
scrap.location_id = scrap.workorder_id.production_id.location_src_id.id
else:
remaining_scrap |= scrap
res = super(StockScrap, remaining_scrap)._compute_location_id()
return res
def _prepare_move_values(self):
vals = super(StockScrap, self)._prepare_move_values()
@ -39,14 +54,46 @@ class StockScrap(models.Model):
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)
message, recommended_location = self.env['stock.quant'].sudo()._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()
@api.onchange('product_id')
def _onchange_product_id(self):
if self.product_is_kit:
self.bom_id = self.env['mrp.bom']._bom_find(self.product_id, company_id=self.company_id.id, bom_type='phantom')[self.product_id]
else:
self.bom_id = False
@api.depends('move_ids', 'move_ids.move_line_ids.quantity', 'product_id')
def _compute_scrap_qty(self):
self.scrap_qty = 1
for scrap in self:
if not scrap.bom_id:
return super(StockScrap, scrap)._compute_scrap_qty()
if scrap.move_ids:
filters = {
'incoming_moves': lambda m: True,
'outgoing_moves': lambda m: False
}
scrap.scrap_qty = scrap.move_ids._compute_kit_quantities(scrap.product_id, scrap.scrap_qty, scrap.bom_id, filters)
def _should_check_available_qty(self):
return super()._should_check_available_qty() or self.product_is_kit
def do_replenish(self, values=False):
self.ensure_one()
values = values or {}
if self.production_id and self.production_id.production_group_id:
values.update({
'production_group_id': self.production_id.production_group_id.id,
})
super().do_replenish(values)

View file

@ -1,16 +1,17 @@
from odoo import models, api
class MrpStockReport(models.TransientModel):
class StockTraceabilityReport(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, res_id, ref = super()._get_reference(move_line)
if move_line.move_id.production_id and move_line.move_id.location_dest_usage != 'inventory':
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:
if move_line.move_id.raw_material_production_id and move_line.move_id.location_dest_usage != 'inventory':
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
@ -26,7 +27,7 @@ class MrpStockReport(models.TransientModel):
@api.model
def _get_linked_move_lines(self, move_line):
move_lines, is_used = super(MrpStockReport, self)._get_linked_move_lines(move_line)
move_lines, is_used = super()._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:

View file

@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo import api, Command, fields, models, _
from odoo.exceptions import ValidationError, UserError
from odoo.tools import split_every
@ -10,38 +10,63 @@ class StockWarehouse(models.Model):
_inherit = 'stock.warehouse'
manufacture_to_resupply = fields.Boolean(
'Manufacture to Resupply', default=True,
'Manufacture to Resupply', compute='_compute_manufacture_to_resupply',
inverse='_inverse_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')
'stock.rule', 'Manufacture Rule', copy=False)
manufacture_mto_pull_id = fields.Many2one(
'stock.rule', 'Manufacture MTO Rule')
'stock.rule', 'Manufacture MTO Rule', copy=False)
pbm_mto_pull_id = fields.Many2one(
'stock.rule', 'Picking Before Manufacturing MTO Rule')
'stock.rule', 'Picking Before Manufacturing MTO Rule', copy=False)
sam_rule_id = fields.Many2one(
'stock.rule', 'Stock After Manufacturing Rule')
'stock.rule', 'Stock After Manufacturing Rule', copy=False)
manu_type_id = fields.Many2one(
'stock.picking.type', 'Manufacturing Operation Type',
domain="[('code', '=', 'mrp_operation'), ('company_id', '=', company_id)]", check_company=True)
domain="[('code', '=', 'mrp_operation'), ('company_id', '=', company_id)]", check_company=True, copy=False)
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)
pbm_type_id = fields.Many2one('stock.picking.type', 'Picking Before Manufacturing Operation Type', check_company=True, copy=False)
sam_type_id = fields.Many2one('stock.picking.type', 'Stock After Manufacturing Operation Type', check_company=True, copy=False)
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)')],
('pbm', 'Pick components then manufacture (2 steps)'),
('pbm_sam', 'Pick components, manufacture, 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.")
help="1 Step: Consume components from stock and produce.\n\
2 Steps: Pick components from stock and then produce.\n\
3 Steps: Pick components from stock, produce, and then move final product(s) from production area to stock.")
pbm_route_id = fields.Many2one('stock.route', 'Picking Before Manufacturing Route', ondelete='restrict')
pbm_route_id = fields.Many2one('stock.route', 'Picking Before Manufacturing Route', ondelete='restrict', copy=False)
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 _compute_manufacture_to_resupply(self):
for warehouse in self:
manufacture_route = warehouse.manufacture_pull_id.route_id
warehouse.manufacture_to_resupply = warehouse.id in manufacture_route.warehouse_ids.ids
def _inverse_manufacture_to_resupply(self):
for warehouse in self:
manufacture_route = warehouse.manufacture_pull_id.route_id
if not manufacture_route:
manufacture_route = self.env['stock.rule'].search([
('action', '=', 'manufacture'), ('warehouse_id', '=', warehouse.id)]).route_id
if not manufacture_route:
continue
if warehouse.manufacture_to_resupply:
manufacture_route.warehouse_ids = [Command.link(warehouse.id)]
else:
manufacture_route.warehouse_ids = [Command.unlink(warehouse.id)]
def _create_or_update_route(self):
manufacture_route = self._find_or_create_global_route('mrp.route_warehouse0_manufacture', _('Manufacture'))
for warehouse in self:
if warehouse.manufacture_to_resupply:
manufacture_route.warehouse_ids = [Command.link(warehouse.id)]
return super()._create_or_update_route()
def get_rules_dict(self):
result = super(StockWarehouse, self).get_rules_dict()
production_location_id = self._get_production_location()
@ -104,11 +129,9 @@ class StockWarehouse(models.Model):
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
def _generate_global_route_rules_values(self):
rules = super()._generate_global_route_rules_values()
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'],
@ -121,25 +144,25 @@ class StockWarehouse(models.Model):
},
'update_values': {
'active': self.manufacture_to_resupply,
'name': self._format_rulename(location_dest_id, False, 'Production'),
'location_dest_id': location_dest_id.id,
'name': self._format_rulename(self.lot_stock_id, False, 'Production'),
'location_dest_id': self.lot_stock_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',
'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,
'route_id': self._find_or_create_global_route('stock.route_warehouse0_mto', _('Replenish on Order (MTO)')).id,
'location_dest_id': production_location.id,
'location_src_id': location_src.id,
'location_src_id': self.lot_stock_id.id,
'picking_type_id': self.manu_type_id.id
},
'update_values': {
'name': self._format_rulename(location_src, production_location, 'MTO'),
'name': self._format_rulename(self.lot_stock_id, production_location, 'MTO'),
'active': self.manufacture_to_resupply,
},
},
@ -150,7 +173,7 @@ class StockWarehouse(models.Model):
'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,
'route_id': self._find_or_create_global_route('stock.route_warehouse0_mto', _('Replenish on Order (MTO)')).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,
@ -160,30 +183,6 @@ class StockWarehouse(models.Model):
'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
@ -199,13 +198,13 @@ class StockWarehouse(models.Model):
'name': _('Pre-Production'),
'active': manufacture_steps in ('pbm', 'pbm_sam'),
'usage': 'internal',
'barcode': self._valid_barcode(code + '-PREPRODUCTION', company_id)
'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)
'barcode': self._valid_barcode(code + 'POSTPRODUCTION', company_id)
},
})
return values
@ -213,9 +212,9 @@ class StockWarehouse(models.Model):
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},
'pbm_type_id': {'name': _('%(name)s Sequence picking before manufacturing', name=self.name), 'prefix': self.code + '/' + (self.pbm_type_id.sequence_code or 'PC') + '/', 'padding': 5, 'company_id': self.company_id.id},
'sam_type_id': {'name': _('%(name)s Sequence stock after manufacturing', name=self.name), 'prefix': self.code + '/' + (self.sam_type_id.sequence_code or 'SFP') + '/', 'padding': 5, 'company_id': self.company_id.id},
'manu_type_id': {'name': _('%(name)s Sequence production', name=self.name), 'prefix': self.code + '/' + (self.manu_type_id.sequence_code or 'MO') + '/', 'padding': 5, 'company_id': self.company_id.id},
})
return values
@ -261,14 +260,15 @@ class StockWarehouse(models.Model):
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",
'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",
'barcode': self.code.replace(" ", "").upper() + "SFP",
},
'manu_type_id': {
'active': self.manufacture_to_resupply and self.active,
'barcode': self.code.replace(" ", "").upper() + "MANUF",
'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,
},
@ -305,14 +305,23 @@ class StockWarehouse(models.Model):
warehouse.manufacture_pull_id.write({'name': warehouse.manufacture_pull_id.name.replace(warehouse.name, name, 1)})
return res
class Orderpoint(models.Model):
class StockWarehouseOrderpoint(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):
domain = [
'&',
'|', ('product_id', 'in', self.product_id.ids),
'&', ('product_id', '=', False),
('product_tmpl_id', 'in', self.product_id.product_tmpl_id.ids),
('type', '=', 'phantom'),
'|',
('company_id', 'in', self.company_id.ids),
('company_id', '=', False),
]
if self.env['mrp.bom'].search_count(domain, limit=1):
raise ValidationError(_("A product with a kit-type bill of materials can not have a reordering rule."))
def _get_orderpoint_products(self):