mirror of
https://github.com/bringout/oca-ocb-mrp.git
synced 2026-04-22 13:32:04 +02:00
Initial commit: Mrp packages
This commit is contained in:
commit
50d736b3bd
739 changed files with 538193 additions and 0 deletions
22
odoo-bringout-oca-ocb-mrp/mrp/models/__init__.py
Normal file
22
odoo-bringout-oca-ocb-mrp/mrp/models/__init__.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import mrp_document
|
||||
from . import res_config_settings
|
||||
from . import mrp_bom
|
||||
from . import mrp_routing
|
||||
from . import mrp_workcenter
|
||||
from . import mrp_production
|
||||
from . import stock_traceability
|
||||
from . import mrp_unbuild
|
||||
from . import mrp_workorder
|
||||
from . import product
|
||||
from . import res_company
|
||||
from . import stock_move
|
||||
from . import stock_orderpoint
|
||||
from . import stock_picking
|
||||
from . import stock_lot
|
||||
from . import stock_rule
|
||||
from . import stock_scrap
|
||||
from . import stock_warehouse
|
||||
from . import stock_quant
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
603
odoo-bringout-oca-ocb-mrp/mrp/models/mrp_bom.py
Normal file
603
odoo-bringout-oca-ocb-mrp/mrp/models/mrp_bom.py
Normal file
|
|
@ -0,0 +1,603 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _, Command
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.osv.expression import AND, OR
|
||||
from odoo.tools import float_round
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
class MrpBom(models.Model):
|
||||
""" Defines bills of material for a product or a product template """
|
||||
_name = 'mrp.bom'
|
||||
_description = 'Bill of Material'
|
||||
_inherit = ['mail.thread']
|
||||
_rec_name = 'product_tmpl_id'
|
||||
_rec_names_search = ['product_tmpl_id', 'code']
|
||||
_order = "sequence, id"
|
||||
_check_company_auto = True
|
||||
|
||||
def _get_default_product_uom_id(self):
|
||||
return self.env['uom.uom'].search([], limit=1, order='id').id
|
||||
|
||||
code = fields.Char('Reference')
|
||||
active = fields.Boolean('Active', default=True)
|
||||
type = fields.Selection([
|
||||
('normal', 'Manufacture this product'),
|
||||
('phantom', 'Kit')], 'BoM Type',
|
||||
default='normal', required=True)
|
||||
product_tmpl_id = fields.Many2one(
|
||||
'product.template', 'Product',
|
||||
check_company=True, index=True,
|
||||
domain="[('type', 'in', ['product', 'consu']), '|', ('company_id', '=', False), ('company_id', '=', company_id)]", required=True)
|
||||
product_id = fields.Many2one(
|
||||
'product.product', 'Product Variant',
|
||||
check_company=True, index=True,
|
||||
domain="['&', ('product_tmpl_id', '=', product_tmpl_id), ('type', 'in', ['product', 'consu']), '|', ('company_id', '=', False), ('company_id', '=', company_id)]",
|
||||
help="If a product variant is defined the BOM is available only for this product.")
|
||||
bom_line_ids = fields.One2many('mrp.bom.line', 'bom_id', 'BoM Lines', copy=True)
|
||||
byproduct_ids = fields.One2many('mrp.bom.byproduct', 'bom_id', 'By-products', copy=True)
|
||||
product_qty = fields.Float(
|
||||
'Quantity', default=1.0,
|
||||
digits='Product Unit of Measure', required=True,
|
||||
help="This should be the smallest quantity that this product can be produced in. If the BOM contains operations, make sure the work center capacity is accurate.")
|
||||
product_uom_id = fields.Many2one(
|
||||
'uom.uom', 'Unit of Measure',
|
||||
default=_get_default_product_uom_id, required=True,
|
||||
help="Unit of Measure (Unit of Measure) is the unit of measurement for the inventory control", domain="[('category_id', '=', product_uom_category_id)]")
|
||||
product_uom_category_id = fields.Many2one(related='product_tmpl_id.uom_id.category_id')
|
||||
sequence = fields.Integer('Sequence')
|
||||
operation_ids = fields.One2many('mrp.routing.workcenter', 'bom_id', 'Operations', copy=True)
|
||||
ready_to_produce = fields.Selection([
|
||||
('all_available', ' When all components are available'),
|
||||
('asap', 'When components for 1st operation are available')], string='Manufacturing Readiness',
|
||||
default='all_available', required=True)
|
||||
picking_type_id = fields.Many2one(
|
||||
'stock.picking.type', 'Operation Type', domain="[('code', '=', 'mrp_operation'), ('company_id', '=', company_id)]",
|
||||
check_company=True,
|
||||
help=u"When a procurement has a ‘produce’ route with a operation type set, it will try to create "
|
||||
"a Manufacturing Order for that product using a BoM of the same operation type. That allows "
|
||||
"to define stock rules which trigger different manufacturing orders with different BoMs.")
|
||||
company_id = fields.Many2one(
|
||||
'res.company', 'Company', index=True,
|
||||
default=lambda self: self.env.company)
|
||||
consumption = fields.Selection([
|
||||
('flexible', 'Allowed'),
|
||||
('warning', 'Allowed with warning'),
|
||||
('strict', 'Blocked')],
|
||||
help="Defines if you can consume more or less components than the quantity defined on the BoM:\n"
|
||||
" * Allowed: allowed for all manufacturing users.\n"
|
||||
" * Allowed with warning: allowed for all manufacturing users with summary of consumption differences when closing the manufacturing order.\n"
|
||||
" Note that in the case of component Manual Consumption, where consumption is registered manually exclusively, consumption warnings will still be issued when appropriate also.\n"
|
||||
" * Blocked: only a manager can close a manufacturing order when the BoM consumption is not respected.",
|
||||
default='warning',
|
||||
string='Flexible Consumption',
|
||||
required=True
|
||||
)
|
||||
possible_product_template_attribute_value_ids = fields.Many2many(
|
||||
'product.template.attribute.value',
|
||||
compute='_compute_possible_product_template_attribute_value_ids')
|
||||
allow_operation_dependencies = fields.Boolean('Operation Dependencies',
|
||||
help="Create operation level dependencies that will influence both planning and the status of work orders upon MO confirmation. If this feature is ticked, and nothing is specified, Odoo will assume that all operations can be started simultaneously."
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('qty_positive', 'check (product_qty > 0)', 'The quantity to produce must be positive!'),
|
||||
]
|
||||
|
||||
@api.depends(
|
||||
'product_tmpl_id.attribute_line_ids.value_ids',
|
||||
'product_tmpl_id.attribute_line_ids.attribute_id.create_variant',
|
||||
'product_tmpl_id.attribute_line_ids.product_template_value_ids.ptav_active',
|
||||
)
|
||||
def _compute_possible_product_template_attribute_value_ids(self):
|
||||
for bom in self:
|
||||
bom.possible_product_template_attribute_value_ids = bom.product_tmpl_id.valid_product_template_attribute_line_ids._without_no_variant_attributes().product_template_value_ids._only_active()
|
||||
|
||||
@api.onchange('product_id')
|
||||
def _onchange_product_id(self):
|
||||
if self.product_id:
|
||||
self.bom_line_ids.bom_product_template_attribute_value_ids = False
|
||||
self.operation_ids.bom_product_template_attribute_value_ids = False
|
||||
self.byproduct_ids.bom_product_template_attribute_value_ids = False
|
||||
|
||||
@api.constrains('active', 'product_id', 'product_tmpl_id', 'bom_line_ids')
|
||||
def _check_bom_cycle(self):
|
||||
subcomponents_dict = dict()
|
||||
|
||||
def _check_cycle(components, finished_products):
|
||||
"""
|
||||
Check whether the components are part of the finished products (-> cycle). Then, if
|
||||
these components have a BoM, repeat the operation with the subcomponents (recursion).
|
||||
The method will return the list of product variants that creates the cycle
|
||||
"""
|
||||
products_to_find = self.env['product.product']
|
||||
|
||||
for component in components:
|
||||
if component in finished_products:
|
||||
names = finished_products.mapped('display_name')
|
||||
raise ValidationError(_("The current configuration is incorrect because it would create a cycle "
|
||||
"between these products: %s.") % ', '.join(names))
|
||||
if component not in subcomponents_dict:
|
||||
products_to_find |= component
|
||||
|
||||
bom_find_result = self._bom_find(products_to_find)
|
||||
for component in components:
|
||||
if component not in subcomponents_dict:
|
||||
bom = bom_find_result[component]
|
||||
subcomponents = bom.bom_line_ids.filtered(lambda l: not l._skip_bom_line(component)).product_id
|
||||
subcomponents_dict[component] = subcomponents
|
||||
subcomponents = subcomponents_dict[component]
|
||||
if subcomponents:
|
||||
_check_cycle(subcomponents, finished_products | component)
|
||||
|
||||
boms_to_check = self
|
||||
domain = []
|
||||
for product in self.bom_line_ids.product_id:
|
||||
domain = OR([domain, self._bom_find_domain(product)])
|
||||
if domain:
|
||||
boms_to_check |= self.env['mrp.bom'].search(domain)
|
||||
|
||||
for bom in boms_to_check:
|
||||
if not bom.active:
|
||||
continue
|
||||
finished_products = bom.product_id or bom.product_tmpl_id.product_variant_ids
|
||||
if bom.bom_line_ids.bom_product_template_attribute_value_ids:
|
||||
grouped_by_components = defaultdict(lambda: self.env['product.product'])
|
||||
for finished in finished_products:
|
||||
components = bom.bom_line_ids.filtered(lambda l: not l._skip_bom_line(finished)).product_id
|
||||
grouped_by_components[components] |= finished
|
||||
for components, finished in grouped_by_components.items():
|
||||
_check_cycle(components, finished)
|
||||
else:
|
||||
_check_cycle(bom.bom_line_ids.product_id, finished_products)
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
if 'sequence' in vals and self and self[-1].id == self._prefetch_ids[-1]:
|
||||
self.browse(self._prefetch_ids)._check_bom_cycle()
|
||||
return res
|
||||
|
||||
@api.constrains('product_id', 'product_tmpl_id', 'bom_line_ids', 'byproduct_ids', 'operation_ids')
|
||||
def _check_bom_lines(self):
|
||||
for bom in self:
|
||||
apply_variants = bom.bom_line_ids.bom_product_template_attribute_value_ids | bom.operation_ids.bom_product_template_attribute_value_ids | bom.byproduct_ids.bom_product_template_attribute_value_ids
|
||||
if bom.product_id and apply_variants:
|
||||
raise ValidationError(_("You cannot use the 'Apply on Variant' functionality and simultaneously create a BoM for a specific variant."))
|
||||
for ptav in apply_variants:
|
||||
if ptav.product_tmpl_id != bom.product_tmpl_id:
|
||||
raise ValidationError(_(
|
||||
"The attribute value %(attribute)s set on product %(product)s does not match the BoM product %(bom_product)s.",
|
||||
attribute=ptav.display_name,
|
||||
product=ptav.product_tmpl_id.display_name,
|
||||
bom_product=bom.product_tmpl_id.display_name
|
||||
))
|
||||
for byproduct in bom.byproduct_ids:
|
||||
if bom.product_id:
|
||||
same_product = bom.product_id == byproduct.product_id
|
||||
else:
|
||||
same_product = bom.product_tmpl_id == byproduct.product_id.product_tmpl_id
|
||||
if same_product:
|
||||
raise ValidationError(_("By-product %s should not be the same as BoM product.") % bom.display_name)
|
||||
if byproduct.cost_share < 0:
|
||||
raise ValidationError(_("By-products cost shares must be positive."))
|
||||
if sum(bom.byproduct_ids.mapped('cost_share')) > 100:
|
||||
raise ValidationError(_("The total cost share for a BoM's by-products cannot exceed 100."))
|
||||
|
||||
@api.onchange('bom_line_ids', 'product_qty', 'product_id', 'product_tmpl_id')
|
||||
def onchange_bom_structure(self):
|
||||
if self.type == 'phantom' and self._origin and self.env['stock.move'].search([('bom_line_id', 'in', self._origin.bom_line_ids.ids)], limit=1):
|
||||
return {
|
||||
'warning': {
|
||||
'title': _('Warning'),
|
||||
'message': _(
|
||||
'The product has already been used at least once, editing its structure may lead to undesirable behaviours. '
|
||||
'You should rather archive the product and create a new one with a new bill of materials.'),
|
||||
}
|
||||
}
|
||||
|
||||
@api.onchange('product_uom_id')
|
||||
def onchange_product_uom_id(self):
|
||||
res = {}
|
||||
if not self.product_uom_id or not self.product_tmpl_id:
|
||||
return
|
||||
if self.product_uom_id.category_id.id != self.product_tmpl_id.uom_id.category_id.id:
|
||||
self.product_uom_id = self.product_tmpl_id.uom_id.id
|
||||
res['warning'] = {'title': _('Warning'), 'message': _('The Product Unit of Measure you chose has a different category than in the product form.')}
|
||||
return res
|
||||
|
||||
@api.onchange('product_tmpl_id')
|
||||
def onchange_product_tmpl_id(self):
|
||||
if self.product_tmpl_id:
|
||||
self.product_uom_id = self.product_tmpl_id.uom_id.id
|
||||
if self.product_id.product_tmpl_id != self.product_tmpl_id:
|
||||
self.product_id = False
|
||||
self.bom_line_ids.bom_product_template_attribute_value_ids = False
|
||||
self.operation_ids.bom_product_template_attribute_value_ids = False
|
||||
self.byproduct_ids.bom_product_template_attribute_value_ids = False
|
||||
|
||||
domain = [('product_tmpl_id', '=', self.product_tmpl_id.id)]
|
||||
if self.id.origin:
|
||||
domain.append(('id', '!=', self.id.origin))
|
||||
number_of_bom_of_this_product = self.env['mrp.bom'].search_count(domain)
|
||||
if number_of_bom_of_this_product: # add a reference to the bom if there is already a bom for this product
|
||||
self.code = _("%s (new) %s", self.product_tmpl_id.name, number_of_bom_of_this_product)
|
||||
else:
|
||||
self.code = False
|
||||
|
||||
def copy(self, default=None):
|
||||
res = super().copy(default)
|
||||
if self.operation_ids:
|
||||
operations_mapping = {}
|
||||
for original, copied in zip(self.operation_ids, res.operation_ids.sorted()):
|
||||
operations_mapping[original] = copied
|
||||
for bom_line in res.bom_line_ids:
|
||||
if bom_line.operation_id:
|
||||
bom_line.operation_id = operations_mapping[bom_line.operation_id]
|
||||
for byproduct in res.byproduct_ids:
|
||||
if byproduct.operation_id:
|
||||
byproduct.operation_id = operations_mapping[byproduct.operation_id]
|
||||
for operation in self.operation_ids:
|
||||
if operation.blocked_by_operation_ids:
|
||||
copied_operation = operations_mapping[operation]
|
||||
dependencies = []
|
||||
for dependency in operation.blocked_by_operation_ids:
|
||||
dependencies.append(Command.link(operations_mapping[dependency].id))
|
||||
copied_operation.blocked_by_operation_ids = dependencies
|
||||
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def name_create(self, name):
|
||||
# prevent to use string as product_tmpl_id
|
||||
if isinstance(name, str):
|
||||
raise UserError(_("You cannot create a new Bill of Material from here."))
|
||||
return super(MrpBom, self).name_create(name)
|
||||
|
||||
def toggle_active(self):
|
||||
self.with_context({'active_test': False}).operation_ids.toggle_active()
|
||||
return super().toggle_active()
|
||||
|
||||
def name_get(self):
|
||||
return [(bom.id, '%s%s' % (bom.code and '%s: ' % bom.code or '', bom.product_tmpl_id.display_name)) for bom in self]
|
||||
|
||||
@api.constrains('product_tmpl_id', 'product_id', 'type')
|
||||
def check_kit_has_not_orderpoint(self):
|
||||
product_ids = [pid for bom in self.filtered(lambda bom: bom.type == "phantom")
|
||||
for pid in (bom.product_id.ids or bom.product_tmpl_id.product_variant_ids.ids)]
|
||||
if self.env['stock.warehouse.orderpoint'].search([('product_id', 'in', product_ids)], count=True):
|
||||
raise ValidationError(_("You can not create a kit-type bill of materials for products that have at least one reordering rule."))
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_except_running_mo(self):
|
||||
if self.env['mrp.production'].search([('bom_id', 'in', self.ids), ('state', 'not in', ['done', 'cancel'])], limit=1):
|
||||
raise UserError(_('You can not delete a Bill of Material with running manufacturing orders.\nPlease close or cancel it first.'))
|
||||
|
||||
@api.model
|
||||
def _bom_find_domain(self, products, picking_type=None, company_id=False, bom_type=False):
|
||||
domain = ['&', '|', ('product_id', 'in', products.ids), '&', ('product_id', '=', False), ('product_tmpl_id', 'in', products.product_tmpl_id.ids), ('active', '=', True)]
|
||||
if company_id or self.env.context.get('company_id'):
|
||||
domain = AND([domain, ['|', ('company_id', '=', False), ('company_id', '=', company_id or self.env.context.get('company_id'))]])
|
||||
if picking_type:
|
||||
domain = AND([domain, ['|', ('picking_type_id', '=', picking_type.id), ('picking_type_id', '=', False)]])
|
||||
if bom_type:
|
||||
domain = AND([domain, [('type', '=', bom_type)]])
|
||||
return domain
|
||||
|
||||
@api.model
|
||||
def _bom_find(self, products, picking_type=None, company_id=False, bom_type=False):
|
||||
""" Find the first BoM for each products
|
||||
|
||||
:param products: `product.product` recordset
|
||||
:return: One bom (or empty recordset `mrp.bom` if none find) by product (`product.product` record)
|
||||
:rtype: defaultdict(`lambda: self.env['mrp.bom']`)
|
||||
"""
|
||||
bom_by_product = defaultdict(lambda: self.env['mrp.bom'])
|
||||
products = products.filtered(lambda p: p.type != 'service')
|
||||
if not products:
|
||||
return bom_by_product
|
||||
domain = self._bom_find_domain(products, picking_type=picking_type, company_id=company_id, bom_type=bom_type)
|
||||
|
||||
# Performance optimization, allow usage of limit and avoid the for loop `bom.product_tmpl_id.product_variant_ids`
|
||||
if len(products) == 1:
|
||||
bom = self.search(domain, order='sequence, product_id, id', limit=1)
|
||||
if bom:
|
||||
bom_by_product[products] = bom
|
||||
return bom_by_product
|
||||
|
||||
boms = self.search(domain, order='sequence, product_id, id')
|
||||
|
||||
products_ids = set(products.ids)
|
||||
for bom in boms:
|
||||
products_implies = bom.product_id or bom.product_tmpl_id.product_variant_ids
|
||||
for product in products_implies:
|
||||
if product.id in products_ids and product not in bom_by_product:
|
||||
bom_by_product[product] = bom
|
||||
|
||||
return bom_by_product
|
||||
|
||||
def explode(self, product, quantity, picking_type=False):
|
||||
"""
|
||||
Explodes the BoM and creates two lists with all the information you need: bom_done and line_done
|
||||
Quantity describes the number of times you need the BoM: so the quantity divided by the number created by the BoM
|
||||
and converted into its UoM
|
||||
"""
|
||||
from collections import defaultdict
|
||||
|
||||
graph = defaultdict(list)
|
||||
V = set()
|
||||
|
||||
def check_cycle(v, visited, recStack, graph):
|
||||
visited[v] = True
|
||||
recStack[v] = True
|
||||
for neighbour in graph[v]:
|
||||
if visited[neighbour] == False:
|
||||
if check_cycle(neighbour, visited, recStack, graph) == True:
|
||||
return True
|
||||
elif recStack[neighbour] == True:
|
||||
return True
|
||||
recStack[v] = False
|
||||
return False
|
||||
|
||||
product_ids = set()
|
||||
product_boms = {}
|
||||
def update_product_boms():
|
||||
products = self.env['product.product'].browse(product_ids)
|
||||
product_boms.update(self._bom_find(products, picking_type=picking_type or self.picking_type_id,
|
||||
company_id=self.company_id.id, bom_type='phantom'))
|
||||
# Set missing keys to default value
|
||||
for product in products:
|
||||
product_boms.setdefault(product, self.env['mrp.bom'])
|
||||
|
||||
boms_done = [(self, {'qty': quantity, 'product': product, 'original_qty': quantity, 'parent_line': False})]
|
||||
lines_done = []
|
||||
V |= set([product.product_tmpl_id.id])
|
||||
|
||||
bom_lines = []
|
||||
for bom_line in self.bom_line_ids:
|
||||
product_id = bom_line.product_id
|
||||
V |= set([product_id.product_tmpl_id.id])
|
||||
graph[product.product_tmpl_id.id].append(product_id.product_tmpl_id.id)
|
||||
bom_lines.append((bom_line, product, quantity, False))
|
||||
product_ids.add(product_id.id)
|
||||
update_product_boms()
|
||||
product_ids.clear()
|
||||
while bom_lines:
|
||||
current_line, current_product, current_qty, parent_line = bom_lines[0]
|
||||
bom_lines = bom_lines[1:]
|
||||
|
||||
if current_line._skip_bom_line(current_product):
|
||||
continue
|
||||
|
||||
line_quantity = current_qty * current_line.product_qty
|
||||
if not current_line.product_id in product_boms:
|
||||
update_product_boms()
|
||||
product_ids.clear()
|
||||
bom = product_boms.get(current_line.product_id)
|
||||
if bom:
|
||||
converted_line_quantity = current_line.product_uom_id._compute_quantity(line_quantity / bom.product_qty, bom.product_uom_id)
|
||||
bom_lines += [(line, current_line.product_id, converted_line_quantity, current_line) for line in bom.bom_line_ids]
|
||||
for bom_line in bom.bom_line_ids:
|
||||
graph[current_line.product_id.product_tmpl_id.id].append(bom_line.product_id.product_tmpl_id.id)
|
||||
if bom_line.product_id.product_tmpl_id.id in V and check_cycle(bom_line.product_id.product_tmpl_id.id, {key: False for key in V}, {key: False for key in V}, graph):
|
||||
raise UserError(_('Recursion error! A product with a Bill of Material should not have itself in its BoM or child BoMs!'))
|
||||
V |= set([bom_line.product_id.product_tmpl_id.id])
|
||||
if not bom_line.product_id in product_boms:
|
||||
product_ids.add(bom_line.product_id.id)
|
||||
boms_done.append((bom, {'qty': converted_line_quantity, 'product': current_product, 'original_qty': quantity, 'parent_line': current_line}))
|
||||
else:
|
||||
# We round up here because the user expects that if he has to consume a little more, the whole UOM unit
|
||||
# should be consumed.
|
||||
rounding = current_line.product_uom_id.rounding
|
||||
line_quantity = float_round(line_quantity, precision_rounding=rounding, rounding_method='UP')
|
||||
lines_done.append((current_line, {'qty': line_quantity, 'product': current_product, 'original_qty': quantity, 'parent_line': parent_line}))
|
||||
|
||||
return boms_done, lines_done
|
||||
|
||||
@api.model
|
||||
def get_import_templates(self):
|
||||
return [{
|
||||
'label': _('Import Template for Bills of Materials'),
|
||||
'template': '/mrp/static/xls/mrp_bom.xls'
|
||||
}]
|
||||
|
||||
|
||||
class MrpBomLine(models.Model):
|
||||
_name = 'mrp.bom.line'
|
||||
_order = "sequence, id"
|
||||
_rec_name = "product_id"
|
||||
_description = 'Bill of Material Line'
|
||||
_check_company_auto = True
|
||||
|
||||
def _get_default_product_uom_id(self):
|
||||
return self.env['uom.uom'].search([], limit=1, order='id').id
|
||||
|
||||
product_id = fields.Many2one('product.product', 'Component', required=True, check_company=True)
|
||||
product_tmpl_id = fields.Many2one('product.template', 'Product Template', related='product_id.product_tmpl_id', store=True, index=True)
|
||||
company_id = fields.Many2one(
|
||||
related='bom_id.company_id', store=True, index=True, readonly=True)
|
||||
product_qty = fields.Float(
|
||||
'Quantity', default=1.0,
|
||||
digits='Product Unit of Measure', required=True)
|
||||
product_uom_id = fields.Many2one(
|
||||
'uom.uom', 'Product Unit of Measure',
|
||||
default=_get_default_product_uom_id,
|
||||
required=True,
|
||||
help="Unit of Measure (Unit of Measure) is the unit of measurement for the inventory control", domain="[('category_id', '=', product_uom_category_id)]")
|
||||
product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id')
|
||||
sequence = fields.Integer(
|
||||
'Sequence', default=1,
|
||||
help="Gives the sequence order when displaying.")
|
||||
bom_id = fields.Many2one(
|
||||
'mrp.bom', 'Parent BoM',
|
||||
index=True, ondelete='cascade', required=True)
|
||||
parent_product_tmpl_id = fields.Many2one('product.template', 'Parent Product Template', related='bom_id.product_tmpl_id')
|
||||
possible_bom_product_template_attribute_value_ids = fields.Many2many(related='bom_id.possible_product_template_attribute_value_ids')
|
||||
bom_product_template_attribute_value_ids = fields.Many2many(
|
||||
'product.template.attribute.value', string="Apply on Variants", ondelete='restrict',
|
||||
domain="[('id', 'in', possible_bom_product_template_attribute_value_ids)]",
|
||||
help="BOM Product Variants needed to apply this line.")
|
||||
allowed_operation_ids = fields.One2many('mrp.routing.workcenter', related='bom_id.operation_ids')
|
||||
operation_id = fields.Many2one(
|
||||
'mrp.routing.workcenter', 'Consumed in Operation', check_company=True,
|
||||
domain="[('id', 'in', allowed_operation_ids)]",
|
||||
help="The operation where the components are consumed, or the finished products created.")
|
||||
child_bom_id = fields.Many2one(
|
||||
'mrp.bom', 'Sub BoM', compute='_compute_child_bom_id')
|
||||
child_line_ids = fields.One2many(
|
||||
'mrp.bom.line', string="BOM lines of the referred bom",
|
||||
compute='_compute_child_line_ids')
|
||||
attachments_count = fields.Integer('Attachments Count', compute='_compute_attachments_count')
|
||||
tracking = fields.Selection(related='product_id.tracking')
|
||||
manual_consumption = fields.Boolean(
|
||||
'Manual Consumption', default=False, compute='_compute_manual_consumption',
|
||||
readonly=False, store=True, copy=True,
|
||||
help="When activated, then the registration of consumption for that component is recorded manually exclusively.\n"
|
||||
"If not activated, and any of the components consumption is edited manually on the manufacturing order, Odoo assumes manual consumption also.")
|
||||
manual_consumption_readonly = fields.Boolean(
|
||||
'Manual Consumption Readonly', compute='_compute_manual_consumption_readonly')
|
||||
|
||||
_sql_constraints = [
|
||||
('bom_qty_zero', 'CHECK (product_qty>=0)', 'All product quantities must be greater or equal to 0.\n'
|
||||
'Lines with 0 quantities can be used as optional lines. \n'
|
||||
'You should install the mrp_byproduct module if you want to manage extra products on BoMs !'),
|
||||
]
|
||||
|
||||
@api.depends('product_id', 'tracking', 'operation_id')
|
||||
def _compute_manual_consumption(self):
|
||||
for line in self:
|
||||
line.manual_consumption = (line.tracking != 'none' or line.operation_id)
|
||||
|
||||
@api.depends('tracking', 'operation_id')
|
||||
def _compute_manual_consumption_readonly(self):
|
||||
for line in self:
|
||||
line.manual_consumption_readonly = (line.tracking != 'none' or line.operation_id)
|
||||
|
||||
@api.depends('product_id', 'bom_id')
|
||||
def _compute_child_bom_id(self):
|
||||
products = self.product_id
|
||||
bom_by_product = self.env['mrp.bom']._bom_find(products)
|
||||
for line in self:
|
||||
if not line.product_id:
|
||||
line.child_bom_id = False
|
||||
else:
|
||||
line.child_bom_id = bom_by_product.get(line.product_id, False)
|
||||
|
||||
@api.depends('product_id')
|
||||
def _compute_attachments_count(self):
|
||||
for line in self:
|
||||
nbr_attach = self.env['mrp.document'].search_count([
|
||||
'|',
|
||||
'&', ('res_model', '=', 'product.product'), ('res_id', '=', line.product_id.id),
|
||||
'&', ('res_model', '=', 'product.template'), ('res_id', '=', line.product_id.product_tmpl_id.id)])
|
||||
line.attachments_count = nbr_attach
|
||||
|
||||
@api.depends('child_bom_id')
|
||||
def _compute_child_line_ids(self):
|
||||
""" If the BOM line refers to a BOM, return the ids of the child BOM lines """
|
||||
for line in self:
|
||||
line.child_line_ids = line.child_bom_id.bom_line_ids.ids or False
|
||||
|
||||
@api.onchange('product_uom_id')
|
||||
def onchange_product_uom_id(self):
|
||||
res = {}
|
||||
if not self.product_uom_id or not self.product_id:
|
||||
return res
|
||||
if self.product_uom_id.category_id != self.product_id.uom_id.category_id:
|
||||
self.product_uom_id = self.product_id.uom_id.id
|
||||
res['warning'] = {'title': _('Warning'), 'message': _('The Product Unit of Measure you chose has a different category than in the product form.')}
|
||||
return res
|
||||
|
||||
@api.onchange('product_id')
|
||||
def onchange_product_id(self):
|
||||
if self.product_id:
|
||||
self.product_uom_id = self.product_id.uom_id.id
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for values in vals_list:
|
||||
if 'product_id' in values and 'product_uom_id' not in values:
|
||||
values['product_uom_id'] = self.env['product.product'].browse(values['product_id']).uom_id.id
|
||||
return super(MrpBomLine, self).create(vals_list)
|
||||
|
||||
def _skip_bom_line(self, product):
|
||||
""" Control if a BoM line should be produced, can be inherited to add
|
||||
custom control.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if product._name == 'product.template':
|
||||
return False
|
||||
return not product._match_all_variant_values(self.bom_product_template_attribute_value_ids)
|
||||
|
||||
def action_see_attachments(self):
|
||||
domain = [
|
||||
'|',
|
||||
'&', ('res_model', '=', 'product.product'), ('res_id', '=', self.product_id.id),
|
||||
'&', ('res_model', '=', 'product.template'), ('res_id', '=', self.product_id.product_tmpl_id.id)]
|
||||
attachment_view = self.env.ref('mrp.view_document_file_kanban_mrp')
|
||||
return {
|
||||
'name': _('Attachments'),
|
||||
'domain': domain,
|
||||
'res_model': 'mrp.document',
|
||||
'type': 'ir.actions.act_window',
|
||||
'view_id': attachment_view.id,
|
||||
'views': [(attachment_view.id, 'kanban'), (False, 'form')],
|
||||
'view_mode': 'kanban,tree,form',
|
||||
'help': _('''<p class="o_view_nocontent_smiling_face">
|
||||
Upload files to your product
|
||||
</p><p>
|
||||
Use this feature to store any files, like drawings or specifications.
|
||||
</p>'''),
|
||||
'limit': 80,
|
||||
'context': "{'default_res_model': '%s','default_res_id': %d, 'default_company_id': %s}" % ('product.product', self.product_id.id, self.company_id.id)
|
||||
}
|
||||
|
||||
|
||||
class MrpByProduct(models.Model):
|
||||
_name = 'mrp.bom.byproduct'
|
||||
_description = 'Byproduct'
|
||||
_rec_name = "product_id"
|
||||
_check_company_auto = True
|
||||
_order = 'sequence, id'
|
||||
|
||||
product_id = fields.Many2one('product.product', 'By-product', required=True, check_company=True)
|
||||
company_id = fields.Many2one(related='bom_id.company_id', store=True, index=True, readonly=True)
|
||||
product_qty = fields.Float(
|
||||
'Quantity',
|
||||
default=1.0, digits='Product Unit of Measure', required=True)
|
||||
product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id')
|
||||
product_uom_id = fields.Many2one('uom.uom', 'Unit of Measure', required=True,
|
||||
compute="_compute_product_uom_id", store=True, readonly=False, precompute=True,
|
||||
domain="[('category_id', '=', product_uom_category_id)]")
|
||||
bom_id = fields.Many2one('mrp.bom', 'BoM', ondelete='cascade', index=True)
|
||||
allowed_operation_ids = fields.One2many('mrp.routing.workcenter', related='bom_id.operation_ids')
|
||||
operation_id = fields.Many2one(
|
||||
'mrp.routing.workcenter', 'Produced in Operation', check_company=True,
|
||||
domain="[('id', 'in', allowed_operation_ids)]")
|
||||
possible_bom_product_template_attribute_value_ids = fields.Many2many(related='bom_id.possible_product_template_attribute_value_ids')
|
||||
bom_product_template_attribute_value_ids = fields.Many2many(
|
||||
'product.template.attribute.value', string="Apply on Variants", ondelete='restrict',
|
||||
domain="[('id', 'in', possible_bom_product_template_attribute_value_ids)]",
|
||||
help="BOM Product Variants needed to apply this line.")
|
||||
sequence = fields.Integer("Sequence")
|
||||
cost_share = fields.Float(
|
||||
"Cost Share (%)", digits=(5, 2), # decimal = 2 is important for rounding calculations!!
|
||||
help="The percentage of the final production cost for this by-product line (divided between the quantity produced)."
|
||||
"The total of all by-products' cost share must be less than or equal to 100.")
|
||||
|
||||
@api.depends('product_id')
|
||||
def _compute_product_uom_id(self):
|
||||
""" Changes UoM if product_id changes. """
|
||||
for record in self:
|
||||
record.product_uom_id = record.product_id.uom_id.id
|
||||
|
||||
def _skip_byproduct_line(self, product):
|
||||
""" Control if a byproduct line should be produced, can be inherited to add
|
||||
custom control.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if product._name == 'product.template':
|
||||
return False
|
||||
return not product._match_all_variant_values(self.bom_product_template_attribute_value_ids)
|
||||
36
odoo-bringout-oca-ocb-mrp/mrp/models/mrp_document.py
Normal file
36
odoo-bringout-oca-ocb-mrp/mrp/models/mrp_document.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class MrpDocument(models.Model):
|
||||
""" Extension of ir.attachment only used in MRP to handle archivage
|
||||
and basic versioning.
|
||||
"""
|
||||
_name = 'mrp.document'
|
||||
_description = "Production Document"
|
||||
_inherits = {
|
||||
'ir.attachment': 'ir_attachment_id',
|
||||
}
|
||||
_order = "priority desc, id desc"
|
||||
|
||||
def copy(self, default=None):
|
||||
ir_default = default
|
||||
if ir_default:
|
||||
ir_fields = list(self.env['ir.attachment']._fields)
|
||||
ir_default = {field : default[field] for field in default.keys() if field in ir_fields}
|
||||
new_attach = self.ir_attachment_id.with_context(no_document=True).copy(ir_default)
|
||||
return super().copy(dict(default, ir_attachment_id=new_attach.id))
|
||||
|
||||
ir_attachment_id = fields.Many2one('ir.attachment', string='Related attachment', required=True, ondelete='cascade')
|
||||
active = fields.Boolean('Active', default=True)
|
||||
priority = fields.Selection([
|
||||
('0', 'Normal'),
|
||||
('1', 'Low'),
|
||||
('2', 'High'),
|
||||
('3', 'Very High')], string="Priority") # used to order
|
||||
|
||||
def unlink(self):
|
||||
self.mapped('ir_attachment_id').unlink()
|
||||
return super(MrpDocument, self).unlink()
|
||||
2350
odoo-bringout-oca-ocb-mrp/mrp/models/mrp_production.py
Normal file
2350
odoo-bringout-oca-ocb-mrp/mrp/models/mrp_production.py
Normal file
File diff suppressed because it is too large
Load diff
167
odoo-bringout-oca-ocb-mrp/mrp/models/mrp_routing.py
Normal file
167
odoo-bringout-oca-ocb-mrp/mrp/models/mrp_routing.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _, tools
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class MrpRoutingWorkcenter(models.Model):
|
||||
_name = 'mrp.routing.workcenter'
|
||||
_description = 'Work Center Usage'
|
||||
_order = 'bom_id, sequence, id'
|
||||
_check_company_auto = True
|
||||
|
||||
name = fields.Char('Operation', required=True)
|
||||
active = fields.Boolean(default=True)
|
||||
workcenter_id = fields.Many2one('mrp.workcenter', 'Work Center', required=True, check_company=True)
|
||||
sequence = fields.Integer(
|
||||
'Sequence', default=100,
|
||||
help="Gives the sequence order when displaying a list of routing Work Centers.")
|
||||
bom_id = fields.Many2one(
|
||||
'mrp.bom', 'Bill of Material',
|
||||
index=True, ondelete='cascade', required=True, check_company=True)
|
||||
company_id = fields.Many2one('res.company', 'Company', related='bom_id.company_id')
|
||||
worksheet_type = fields.Selection([
|
||||
('pdf', 'PDF'), ('google_slide', 'Google Slide'), ('text', 'Text')],
|
||||
string="Worksheet", default="text"
|
||||
)
|
||||
note = fields.Html('Description')
|
||||
worksheet = fields.Binary('PDF')
|
||||
worksheet_google_slide = fields.Char('Google Slide', help="Paste the url of your Google Slide. Make sure the access to the document is public.")
|
||||
time_mode = fields.Selection([
|
||||
('auto', 'Compute based on tracked time'),
|
||||
('manual', 'Set duration manually')], string='Duration Computation',
|
||||
default='manual')
|
||||
time_mode_batch = fields.Integer('Based on', default=10)
|
||||
time_computed_on = fields.Char('Computed on last', compute='_compute_time_computed_on')
|
||||
time_cycle_manual = fields.Float(
|
||||
'Manual Duration', default=60,
|
||||
help="Time in minutes:"
|
||||
"- In manual mode, time used"
|
||||
"- In automatic mode, supposed first time when there aren't any work orders yet")
|
||||
time_cycle = fields.Float('Duration', compute="_compute_time_cycle")
|
||||
workorder_count = fields.Integer("# Work Orders", compute="_compute_workorder_count")
|
||||
workorder_ids = fields.One2many('mrp.workorder', 'operation_id', string="Work Orders")
|
||||
possible_bom_product_template_attribute_value_ids = fields.Many2many(related='bom_id.possible_product_template_attribute_value_ids')
|
||||
bom_product_template_attribute_value_ids = fields.Many2many(
|
||||
'product.template.attribute.value', string="Apply on Variants", ondelete='restrict',
|
||||
domain="[('id', 'in', possible_bom_product_template_attribute_value_ids)]",
|
||||
help="BOM Product Variants needed to apply this line.")
|
||||
allow_operation_dependencies = fields.Boolean(related='bom_id.allow_operation_dependencies')
|
||||
blocked_by_operation_ids = fields.Many2many('mrp.routing.workcenter', relation="mrp_routing_workcenter_dependencies_rel",
|
||||
column1="operation_id", column2="blocked_by_id",
|
||||
string="Blocked By", help="Operations that need to be completed before this operation can start.",
|
||||
domain="[('allow_operation_dependencies', '=', True), ('id', '!=', id), ('bom_id', '=', bom_id)]",
|
||||
copy=False)
|
||||
needed_by_operation_ids = fields.Many2many('mrp.routing.workcenter', relation="mrp_routing_workcenter_dependencies_rel",
|
||||
column1="blocked_by_id", column2="operation_id",
|
||||
string="Blocks", help="Operations that cannot start before this operation is completed.",
|
||||
domain="[('allow_operation_dependencies', '=', True), ('id', '!=', id), ('bom_id', '=', bom_id)]",
|
||||
copy=False)
|
||||
|
||||
@api.depends('time_mode', 'time_mode_batch')
|
||||
def _compute_time_computed_on(self):
|
||||
for operation in self:
|
||||
operation.time_computed_on = _('%i work orders') % operation.time_mode_batch if operation.time_mode != 'manual' else False
|
||||
|
||||
@api.depends('time_cycle_manual', 'time_mode', 'workorder_ids')
|
||||
def _compute_time_cycle(self):
|
||||
manual_ops = self.filtered(lambda operation: operation.time_mode == 'manual')
|
||||
for operation in manual_ops:
|
||||
operation.time_cycle = operation.time_cycle_manual
|
||||
for operation in self - manual_ops:
|
||||
data = self.env['mrp.workorder'].search([
|
||||
('operation_id', '=', operation.id),
|
||||
('qty_produced', '>', 0),
|
||||
('state', '=', 'done')],
|
||||
limit=operation.time_mode_batch,
|
||||
order="date_finished desc, id desc")
|
||||
# To compute the time_cycle, we can take the total duration of previous operations
|
||||
# but for the quantity, we will take in consideration the qty_produced like if the capacity was 1.
|
||||
# So producing 50 in 00:10 with capacity 2, for the time_cycle, we assume it is 25 in 00:10
|
||||
# When recomputing the expected duration, the capacity is used again to divide the qty to produce
|
||||
# so that if we need 50 with capacity 2, it will compute the expected of 25 which is 00:10
|
||||
total_duration = 0 # Can be 0 since it's not an invalid duration for BoM
|
||||
cycle_number = 0 # Never 0 unless infinite item['workcenter_id'].capacity
|
||||
for item in data:
|
||||
total_duration += item['duration']
|
||||
capacity = item['workcenter_id']._get_capacity(item.product_id)
|
||||
qty_produced = item.product_uom_id._compute_quantity(item['qty_produced'], item.product_id.uom_id)
|
||||
cycle_number += tools.float_round((qty_produced / capacity or 1.0), precision_digits=0, rounding_method='UP')
|
||||
if cycle_number:
|
||||
operation.time_cycle = total_duration / cycle_number
|
||||
else:
|
||||
operation.time_cycle = operation.time_cycle_manual
|
||||
|
||||
def _compute_workorder_count(self):
|
||||
data = self.env['mrp.workorder']._read_group([
|
||||
('operation_id', 'in', self.ids),
|
||||
('state', '=', 'done')], ['operation_id'], ['operation_id'])
|
||||
count_data = dict((item['operation_id'][0], item['operation_id_count']) for item in data)
|
||||
for operation in self:
|
||||
operation.workorder_count = count_data.get(operation.id, 0)
|
||||
|
||||
@api.constrains('blocked_by_operation_ids')
|
||||
def _check_no_cyclic_dependencies(self):
|
||||
if not self._check_m2m_recursion('blocked_by_operation_ids'):
|
||||
raise ValidationError(_("You cannot create cyclic dependency."))
|
||||
|
||||
def action_archive(self):
|
||||
res = super().action_archive()
|
||||
bom_lines = self.env['mrp.bom.line'].search([('operation_id', 'in', self.ids)])
|
||||
bom_lines.write({'operation_id': False})
|
||||
byproduct_lines = self.env['mrp.bom.byproduct'].search([('operation_id', 'in', self.ids)])
|
||||
byproduct_lines.write({'operation_id': False})
|
||||
return res
|
||||
|
||||
def copy_to_bom(self):
|
||||
if 'bom_id' in self.env.context:
|
||||
bom_id = self.env.context.get('bom_id')
|
||||
for operation in self:
|
||||
operation.copy({'bom_id': bom_id})
|
||||
return {
|
||||
'view_mode': 'form',
|
||||
'res_model': 'mrp.bom',
|
||||
'views': [(False, 'form')],
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_id': bom_id,
|
||||
}
|
||||
|
||||
def copy_existing_operations(self):
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('Select Operations to Copy'),
|
||||
'res_model': 'mrp.routing.workcenter',
|
||||
'view_mode': 'tree,form',
|
||||
'domain': ['|', ('bom_id', '=', False), ('bom_id.active', '=', True)],
|
||||
'context' : {
|
||||
'bom_id': self.env.context["bom_id"],
|
||||
'tree_view_ref': 'mrp.mrp_routing_workcenter_copy_to_bom_tree_view',
|
||||
}
|
||||
}
|
||||
|
||||
def _skip_operation_line(self, product):
|
||||
""" Control if a operation should be processed, can be inherited to add
|
||||
custom control.
|
||||
"""
|
||||
self.ensure_one()
|
||||
# skip operation line if archived
|
||||
if not self.active:
|
||||
return True
|
||||
if product._name == 'product.template':
|
||||
return False
|
||||
return not product._match_all_variant_values(self.bom_product_template_attribute_value_ids)
|
||||
|
||||
def _get_comparison_values(self):
|
||||
if not self:
|
||||
return False
|
||||
self.ensure_one()
|
||||
return tuple(self[key] for key in ('name', 'company_id', 'workcenter_id', 'time_mode', 'time_cycle_manual', 'bom_product_template_attribute_value_ids'))
|
||||
|
||||
def write(self, values):
|
||||
if 'bom_id' in values:
|
||||
for op in self:
|
||||
op.bom_id.bom_line_ids.filtered(lambda line: line.operation_id == op).operation_id = False
|
||||
op.bom_id.byproduct_ids.filtered(lambda byproduct: byproduct.operation_id == op).operation_id = False
|
||||
op.bom_id.operation_ids.filtered(lambda operation: operation.blocked_by_operation_ids == op).blocked_by_operation_ids = False
|
||||
return super().write(values)
|
||||
319
odoo-bringout-oca-ocb-mrp/mrp/models/mrp_unbuild.py
Normal file
319
odoo-bringout-oca-ocb-mrp/mrp/models/mrp_unbuild.py
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.tools import float_compare, float_round
|
||||
from odoo.osv import expression
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
class MrpUnbuild(models.Model):
|
||||
_name = "mrp.unbuild"
|
||||
_description = "Unbuild Order"
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
_order = 'id desc'
|
||||
|
||||
name = fields.Char('Reference', copy=False, readonly=True, default=lambda x: _('New'))
|
||||
product_id = fields.Many2one(
|
||||
'product.product', 'Product', check_company=True,
|
||||
domain="[('type', 'in', ['product', 'consu']), '|', ('company_id', '=', False), ('company_id', '=', company_id)]",
|
||||
required=True, states={'done': [('readonly', True)]})
|
||||
company_id = fields.Many2one(
|
||||
'res.company', 'Company',
|
||||
default=lambda s: s.env.company,
|
||||
required=True, index=True, states={'done': [('readonly', True)]})
|
||||
product_qty = fields.Float(
|
||||
'Quantity', default=1.0,
|
||||
required=True, states={'done': [('readonly', True)]})
|
||||
product_uom_id = fields.Many2one(
|
||||
'uom.uom', 'Unit of Measure',
|
||||
compute='_compute_product_uom_id', store=True, readonly=False, precompute=True,
|
||||
required=True, states={'done': [('readonly', True)]})
|
||||
bom_id = fields.Many2one(
|
||||
'mrp.bom', 'Bill of Material',
|
||||
domain="""[
|
||||
'|',
|
||||
('product_id', '=', product_id),
|
||||
'&',
|
||||
('product_tmpl_id.product_variant_ids', '=', product_id),
|
||||
('product_id','=',False),
|
||||
('type', '=', 'normal'),
|
||||
'|',
|
||||
('company_id', '=', company_id),
|
||||
('company_id', '=', False)
|
||||
]
|
||||
""",
|
||||
states={'done': [('readonly', True)]}, check_company=True)
|
||||
mo_id = fields.Many2one(
|
||||
'mrp.production', 'Manufacturing Order',
|
||||
domain="[('state', '=', 'done'), ('company_id', '=', company_id), ('product_id', '=?', product_id), ('bom_id', '=?', bom_id)]",
|
||||
states={'done': [('readonly', True)]}, check_company=True)
|
||||
mo_bom_id = fields.Many2one('mrp.bom', 'Bill of Material used on the Production Order', related='mo_id.bom_id')
|
||||
lot_id = fields.Many2one(
|
||||
'stock.lot', 'Lot/Serial Number',
|
||||
domain="[('product_id', '=', product_id), ('company_id', '=', company_id)]", check_company=True)
|
||||
has_tracking=fields.Selection(related='product_id.tracking', readonly=True)
|
||||
location_id = fields.Many2one(
|
||||
'stock.location', 'Source Location',
|
||||
domain="[('usage','=','internal'), '|', ('company_id', '=', False), ('company_id', '=', company_id)]",
|
||||
check_company=True,
|
||||
compute='_compute_location_id', store=True, readonly=False, precompute=True,
|
||||
required=True, states={'done': [('readonly', True)]}, help="Location where the product you want to unbuild is.")
|
||||
location_dest_id = fields.Many2one(
|
||||
'stock.location', 'Destination Location',
|
||||
domain="[('usage','=','internal'), '|', ('company_id', '=', False), ('company_id', '=', company_id)]",
|
||||
check_company=True,
|
||||
compute='_compute_location_id', store=True, readonly=False, precompute=True,
|
||||
required=True, states={'done': [('readonly', True)]}, help="Location where you want to send the components resulting from the unbuild order.")
|
||||
consume_line_ids = fields.One2many(
|
||||
'stock.move', 'consume_unbuild_id', readonly=True,
|
||||
string='Consumed Disassembly Lines')
|
||||
produce_line_ids = fields.One2many(
|
||||
'stock.move', 'unbuild_id', readonly=True,
|
||||
string='Processed Disassembly Lines')
|
||||
state = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('done', 'Done')], string='Status', default='draft')
|
||||
|
||||
@api.depends('mo_id', 'product_id')
|
||||
def _compute_product_uom_id(self):
|
||||
for record in self:
|
||||
if record.mo_id.product_id and record.mo_id.product_id == record.product_id:
|
||||
record.product_uom_id = record.mo_id.product_uom_id.id
|
||||
else:
|
||||
record.product_uom_id = record.product_id.uom_id.id
|
||||
|
||||
@api.depends('company_id')
|
||||
def _compute_location_id(self):
|
||||
for order in self:
|
||||
if order.company_id:
|
||||
warehouse = self.env['stock.warehouse'].search([('company_id', '=', order.company_id.id)], limit=1)
|
||||
if order.location_id.company_id != order.company_id:
|
||||
order.location_id = warehouse.lot_stock_id
|
||||
if order.location_dest_id.company_id != order.company_id:
|
||||
order.location_dest_id = warehouse.lot_stock_id
|
||||
|
||||
@api.onchange('mo_id')
|
||||
def _onchange_mo_id(self):
|
||||
if self.mo_id:
|
||||
self.product_id = self.mo_id.product_id.id
|
||||
self.bom_id = self.mo_id.bom_id
|
||||
self.product_uom_id = self.mo_id.product_uom_id
|
||||
self.lot_id = self.mo_id.lot_producing_id
|
||||
if self.has_tracking == 'serial':
|
||||
self.product_qty = 1
|
||||
else:
|
||||
self.product_qty = self.mo_id.qty_produced
|
||||
|
||||
|
||||
@api.onchange('product_id')
|
||||
def _onchange_product_id(self):
|
||||
if self.product_id:
|
||||
self.bom_id = self.env['mrp.bom']._bom_find(self.product_id, company_id=self.company_id.id)[self.product_id]
|
||||
self.product_uom_id = self.mo_id.product_id == self.product_id and self.mo_id.product_uom_id.id or self.product_id.uom_id.id
|
||||
|
||||
@api.constrains('product_qty')
|
||||
def _check_qty(self):
|
||||
for unbuild in self:
|
||||
if unbuild.product_qty <= 0:
|
||||
raise ValidationError(_('Unbuild Order product quantity has to be strictly positive.'))
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if not vals.get('name') or vals['name'] == _('New'):
|
||||
vals['name'] = self.env['ir.sequence'].next_by_code('mrp.unbuild') or _('New')
|
||||
return super().create(vals_list)
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_except_done(self):
|
||||
if 'done' in self.mapped('state'):
|
||||
raise UserError(_("You cannot delete an unbuild order if the state is 'Done'."))
|
||||
|
||||
def _prepare_finished_move_line_vals(self, finished_move):
|
||||
return {
|
||||
'move_id': finished_move.id,
|
||||
'lot_id': self.lot_id.id,
|
||||
'qty_done': finished_move.product_uom_qty,
|
||||
'product_id': finished_move.product_id.id,
|
||||
'product_uom_id': finished_move.product_uom.id,
|
||||
'location_id': finished_move.location_id.id,
|
||||
'location_dest_id': finished_move.location_dest_id.id,
|
||||
}
|
||||
|
||||
def _prepare_move_line_vals(self, move, origin_move_line, taken_quantity):
|
||||
return {
|
||||
'move_id': move.id,
|
||||
'lot_id': origin_move_line.lot_id.id,
|
||||
'qty_done': taken_quantity,
|
||||
'product_id': move.product_id.id,
|
||||
'product_uom_id': origin_move_line.product_uom_id.id,
|
||||
'location_id': move.location_id.id,
|
||||
'location_dest_id': move.location_dest_id.id,
|
||||
}
|
||||
|
||||
def action_unbuild(self):
|
||||
self.ensure_one()
|
||||
self._check_company()
|
||||
if self.product_id.tracking != 'none' and not self.lot_id.id:
|
||||
raise UserError(_('You should provide a lot number for the final product.'))
|
||||
|
||||
if self.mo_id:
|
||||
if self.mo_id.state != 'done':
|
||||
raise UserError(_('You cannot unbuild a undone manufacturing order.'))
|
||||
|
||||
consume_moves = self._generate_consume_moves()
|
||||
consume_moves._action_confirm()
|
||||
produce_moves = self._generate_produce_moves()
|
||||
produce_moves.with_context(default_lot_id=False)._action_confirm()
|
||||
|
||||
finished_moves = consume_moves.filtered(lambda m: m.product_id == self.product_id)
|
||||
consume_moves -= finished_moves
|
||||
|
||||
if any(produce_move.has_tracking != 'none' and not self.mo_id for produce_move in produce_moves):
|
||||
raise UserError(_('Some of your components are tracked, you have to specify a manufacturing order in order to retrieve the correct components.'))
|
||||
|
||||
if any(consume_move.has_tracking != 'none' and not self.mo_id for consume_move in consume_moves):
|
||||
raise UserError(_('Some of your byproducts are tracked, you have to specify a manufacturing order in order to retrieve the correct byproducts.'))
|
||||
|
||||
for finished_move in finished_moves:
|
||||
if finished_move.has_tracking != 'none':
|
||||
finished_move_line_vals = self._prepare_finished_move_line_vals(finished_move)
|
||||
self.env["stock.move.line"].create(finished_move_line_vals)
|
||||
else:
|
||||
finished_move.quantity_done = self.product_qty
|
||||
|
||||
# TODO: Will fail if user do more than one unbuild with lot on the same MO. Need to check what other unbuild has aready took
|
||||
qty_already_used = defaultdict(float)
|
||||
for move in produce_moves | consume_moves:
|
||||
if move.has_tracking != 'none':
|
||||
original_move = move in produce_moves and self.mo_id.move_raw_ids or self.mo_id.move_finished_ids
|
||||
original_move = original_move.filtered(lambda m: m.product_id == move.product_id)
|
||||
needed_quantity = move.product_uom_qty
|
||||
moves_lines = original_move.mapped('move_line_ids')
|
||||
if move in produce_moves and self.lot_id:
|
||||
moves_lines = moves_lines.filtered(lambda ml: self.lot_id in ml.produce_line_ids.lot_id) # FIXME sle: double check with arm
|
||||
for move_line in moves_lines:
|
||||
# Iterate over all move_lines until we unbuilded the correct quantity.
|
||||
taken_quantity = min(needed_quantity, move_line.qty_done - qty_already_used[move_line])
|
||||
if taken_quantity:
|
||||
move_line_vals = self._prepare_move_line_vals(move, move_line, taken_quantity)
|
||||
self.env["stock.move.line"].create(move_line_vals)
|
||||
needed_quantity -= taken_quantity
|
||||
qty_already_used[move_line] += taken_quantity
|
||||
else:
|
||||
move.quantity_done = float_round(move.product_uom_qty, precision_rounding=move.product_uom.rounding)
|
||||
|
||||
finished_moves._action_done()
|
||||
consume_moves._action_done()
|
||||
produce_moves._action_done()
|
||||
produced_move_line_ids = produce_moves.mapped('move_line_ids').filtered(lambda ml: ml.qty_done > 0)
|
||||
consume_moves.mapped('move_line_ids').write({'produce_line_ids': [(6, 0, produced_move_line_ids.ids)]})
|
||||
if self.mo_id:
|
||||
unbuild_msg = _(
|
||||
"%(qty)s %(measure)s unbuilt in %(order)s",
|
||||
qty=self.product_qty,
|
||||
measure=self.product_uom_id.name,
|
||||
order=self._get_html_link(),
|
||||
)
|
||||
self.mo_id.message_post(
|
||||
body=unbuild_msg,
|
||||
subtype_id=self.env.ref('mail.mt_note').id)
|
||||
return self.write({'state': 'done'})
|
||||
|
||||
def _generate_consume_moves(self):
|
||||
moves = self.env['stock.move']
|
||||
for unbuild in self:
|
||||
if unbuild.mo_id:
|
||||
finished_moves = unbuild.mo_id.move_finished_ids.filtered(lambda move: move.state == 'done')
|
||||
moved_qty = unbuild.mo_id.product_uom_id._compute_quantity(unbuild.mo_id.qty_produced, unbuild.product_uom_id)
|
||||
factor = unbuild.product_qty / moved_qty if moved_qty else 0
|
||||
for finished_move in finished_moves:
|
||||
moves += unbuild._generate_move_from_existing_move(finished_move, factor, unbuild.location_id, finished_move.location_id)
|
||||
else:
|
||||
factor = unbuild.product_uom_id._compute_quantity(unbuild.product_qty, unbuild.bom_id.product_uom_id) / unbuild.bom_id.product_qty
|
||||
moves += unbuild._generate_move_from_bom_line(self.product_id, self.product_uom_id, unbuild.product_qty)
|
||||
for byproduct in unbuild.bom_id.byproduct_ids:
|
||||
if byproduct._skip_byproduct_line(unbuild.product_id):
|
||||
continue
|
||||
quantity = byproduct.product_qty * factor
|
||||
moves += unbuild._generate_move_from_bom_line(byproduct.product_id, byproduct.product_uom_id, quantity, byproduct_id=byproduct.id)
|
||||
return moves
|
||||
|
||||
def _generate_produce_moves(self):
|
||||
moves = self.env['stock.move']
|
||||
for unbuild in self:
|
||||
if unbuild.mo_id:
|
||||
raw_moves = unbuild.mo_id.move_raw_ids.filtered(lambda move: move.state == 'done')
|
||||
factor = unbuild.product_qty / unbuild.mo_id.product_uom_id._compute_quantity(unbuild.mo_id.qty_produced, unbuild.product_uom_id)
|
||||
for raw_move in raw_moves:
|
||||
moves += unbuild._generate_move_from_existing_move(raw_move, factor, raw_move.location_dest_id, self.location_dest_id)
|
||||
else:
|
||||
factor = unbuild.product_uom_id._compute_quantity(unbuild.product_qty, unbuild.bom_id.product_uom_id) / unbuild.bom_id.product_qty
|
||||
boms, lines = unbuild.bom_id.explode(unbuild.product_id, factor, picking_type=unbuild.bom_id.picking_type_id)
|
||||
for line, line_data in lines:
|
||||
moves += unbuild._generate_move_from_bom_line(line.product_id, line.product_uom_id, line_data['qty'], bom_line_id=line.id)
|
||||
return moves
|
||||
|
||||
def _generate_move_from_existing_move(self, move, factor, location_id, location_dest_id):
|
||||
return self.env['stock.move'].create({
|
||||
'name': self.name,
|
||||
'date': self.create_date,
|
||||
'product_id': move.product_id.id,
|
||||
'product_uom_qty': move.quantity_done * factor,
|
||||
'product_uom': move.product_uom.id,
|
||||
'procure_method': 'make_to_stock',
|
||||
'location_dest_id': location_dest_id.id,
|
||||
'location_id': location_id.id,
|
||||
'warehouse_id': location_dest_id.warehouse_id.id,
|
||||
'unbuild_id': self.id,
|
||||
'company_id': move.company_id.id,
|
||||
'origin_returned_move_id': move.id,
|
||||
})
|
||||
|
||||
def _generate_move_from_bom_line(self, product, product_uom, quantity, bom_line_id=False, byproduct_id=False):
|
||||
product_prod_location = product.with_company(self.company_id).property_stock_production
|
||||
location_id = bom_line_id and product_prod_location or self.location_id
|
||||
location_dest_id = bom_line_id and self.location_dest_id or product_prod_location
|
||||
warehouse = location_dest_id.warehouse_id
|
||||
return self.env['stock.move'].create({
|
||||
'name': self.name,
|
||||
'date': self.create_date,
|
||||
'bom_line_id': bom_line_id,
|
||||
'byproduct_id': byproduct_id,
|
||||
'product_id': product.id,
|
||||
'product_uom_qty': quantity,
|
||||
'product_uom': product_uom.id,
|
||||
'procure_method': 'make_to_stock',
|
||||
'location_dest_id': location_dest_id.id,
|
||||
'location_id': location_id.id,
|
||||
'warehouse_id': warehouse.id,
|
||||
'unbuild_id': self.id,
|
||||
'company_id': self.company_id.id,
|
||||
})
|
||||
|
||||
def action_validate(self):
|
||||
self.ensure_one()
|
||||
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
|
||||
available_qty = self.env['stock.quant']._get_available_quantity(self.product_id, self.location_id, self.lot_id, strict=True)
|
||||
unbuild_qty = self.product_uom_id._compute_quantity(self.product_qty, self.product_id.uom_id)
|
||||
if float_compare(available_qty, unbuild_qty, precision_digits=precision) >= 0:
|
||||
return self.action_unbuild()
|
||||
else:
|
||||
return {
|
||||
'name': self.product_id.display_name + _(': Insufficient Quantity To Unbuild'),
|
||||
'view_mode': 'form',
|
||||
'res_model': 'stock.warn.insufficient.qty.unbuild',
|
||||
'view_id': self.env.ref('mrp.stock_warn_insufficient_qty_unbuild_form_view').id,
|
||||
'type': 'ir.actions.act_window',
|
||||
'context': {
|
||||
'default_product_id': self.product_id.id,
|
||||
'default_location_id': self.location_id.id,
|
||||
'default_unbuild_id': self.id,
|
||||
'default_quantity': unbuild_qty,
|
||||
'default_product_uom_name': self.product_id.uom_name
|
||||
},
|
||||
'target': 'new'
|
||||
}
|
||||
464
odoo-bringout-oca-ocb-mrp/mrp/models/mrp_workcenter.py
Normal file
464
odoo-bringout-oca-ocb-mrp/mrp/models/mrp_workcenter.py
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from dateutil import relativedelta
|
||||
from datetime import timedelta, datetime
|
||||
from functools import partial
|
||||
from pytz import timezone
|
||||
from random import randint
|
||||
|
||||
from odoo import api, exceptions, fields, models, _
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.addons.resource.models.resource import make_aware, Intervals
|
||||
from odoo.tools.float_utils import float_compare
|
||||
|
||||
|
||||
class MrpWorkcenter(models.Model):
|
||||
_name = 'mrp.workcenter'
|
||||
_description = 'Work Center'
|
||||
_order = "sequence, id"
|
||||
_inherit = ['resource.mixin']
|
||||
_check_company_auto = True
|
||||
|
||||
# resource
|
||||
name = fields.Char('Work Center', related='resource_id.name', store=True, readonly=False)
|
||||
time_efficiency = fields.Float('Time Efficiency', related='resource_id.time_efficiency', default=100, store=True, readonly=False)
|
||||
active = fields.Boolean('Active', related='resource_id.active', default=True, store=True, readonly=False)
|
||||
|
||||
code = fields.Char('Code', copy=False)
|
||||
note = fields.Html(
|
||||
'Description')
|
||||
default_capacity = fields.Float(
|
||||
'Capacity', default=1.0,
|
||||
help="Default number of pieces (in product UoM) that can be produced in parallel (at the same time) at this work center. For example: the capacity is 5 and you need to produce 10 units, then the operation time listed on the BOM will be multiplied by two. However, note that both time before and after production will only be counted once.")
|
||||
sequence = fields.Integer(
|
||||
'Sequence', default=1, required=True,
|
||||
help="Gives the sequence order when displaying a list of work centers.")
|
||||
color = fields.Integer('Color')
|
||||
currency_id = fields.Many2one('res.currency', 'Currency', related='company_id.currency_id', readonly=True, required=True)
|
||||
costs_hour = fields.Float(string='Cost per hour', help='Hourly processing cost.', default=0.0)
|
||||
time_start = fields.Float('Setup Time')
|
||||
time_stop = fields.Float('Cleanup Time')
|
||||
routing_line_ids = fields.One2many('mrp.routing.workcenter', 'workcenter_id', "Routing Lines")
|
||||
order_ids = fields.One2many('mrp.workorder', 'workcenter_id', "Orders")
|
||||
workorder_count = fields.Integer('# Work Orders', compute='_compute_workorder_count')
|
||||
workorder_ready_count = fields.Integer('# Read Work Orders', compute='_compute_workorder_count')
|
||||
workorder_progress_count = fields.Integer('Total Running Orders', compute='_compute_workorder_count')
|
||||
workorder_pending_count = fields.Integer('Total Pending Orders', compute='_compute_workorder_count')
|
||||
workorder_late_count = fields.Integer('Total Late Orders', compute='_compute_workorder_count')
|
||||
|
||||
time_ids = fields.One2many('mrp.workcenter.productivity', 'workcenter_id', 'Time Logs')
|
||||
working_state = fields.Selection([
|
||||
('normal', 'Normal'),
|
||||
('blocked', 'Blocked'),
|
||||
('done', 'In Progress')], 'Workcenter Status', compute="_compute_working_state", store=True)
|
||||
blocked_time = fields.Float(
|
||||
'Blocked Time', compute='_compute_blocked_time',
|
||||
help='Blocked hours over the last month', digits=(16, 2))
|
||||
productive_time = fields.Float(
|
||||
'Productive Time', compute='_compute_productive_time',
|
||||
help='Productive hours over the last month', digits=(16, 2))
|
||||
oee = fields.Float(compute='_compute_oee', help='Overall Equipment Effectiveness, based on the last month')
|
||||
oee_target = fields.Float(string='OEE Target', help="Overall Effective Efficiency Target in percentage", default=90)
|
||||
performance = fields.Integer('Performance', compute='_compute_performance', help='Performance over the last month')
|
||||
workcenter_load = fields.Float('Work Center Load', compute='_compute_workorder_count')
|
||||
alternative_workcenter_ids = fields.Many2many(
|
||||
'mrp.workcenter',
|
||||
'mrp_workcenter_alternative_rel',
|
||||
'workcenter_id',
|
||||
'alternative_workcenter_id',
|
||||
domain="[('id', '!=', id), '|', ('company_id', '=', company_id), ('company_id', '=', False)]",
|
||||
string="Alternative Workcenters", check_company=True,
|
||||
help="Alternative workcenters that can be substituted to this one in order to dispatch production"
|
||||
)
|
||||
tag_ids = fields.Many2many('mrp.workcenter.tag')
|
||||
capacity_ids = fields.One2many('mrp.workcenter.capacity', 'workcenter_id', string='Product Capacities',
|
||||
help="Specific number of pieces that can be produced in parallel per product.", copy=True)
|
||||
|
||||
@api.constrains('alternative_workcenter_ids')
|
||||
def _check_alternative_workcenter(self):
|
||||
for workcenter in self:
|
||||
if workcenter in workcenter.alternative_workcenter_ids:
|
||||
raise ValidationError(_("Workcenter %s cannot be an alternative of itself.", workcenter.name))
|
||||
|
||||
@api.depends('order_ids.duration_expected', 'order_ids.workcenter_id', 'order_ids.state', 'order_ids.date_planned_start')
|
||||
def _compute_workorder_count(self):
|
||||
MrpWorkorder = self.env['mrp.workorder']
|
||||
result = {wid: {} for wid in self._ids}
|
||||
result_duration_expected = {wid: 0 for wid in self._ids}
|
||||
# Count Late Workorder
|
||||
data = MrpWorkorder._read_group(
|
||||
[('workcenter_id', 'in', self.ids), ('state', 'in', ('pending', 'waiting', 'ready')), ('date_planned_start', '<', datetime.now().strftime('%Y-%m-%d'))],
|
||||
['workcenter_id'], ['workcenter_id'])
|
||||
count_data = dict((item['workcenter_id'][0], item['workcenter_id_count']) for item in data)
|
||||
# Count All, Pending, Ready, Progress Workorder
|
||||
res = MrpWorkorder._read_group(
|
||||
[('workcenter_id', 'in', self.ids)],
|
||||
['workcenter_id', 'state', 'duration_expected'], ['workcenter_id', 'state'],
|
||||
lazy=False)
|
||||
for res_group in res:
|
||||
result[res_group['workcenter_id'][0]][res_group['state']] = res_group['__count']
|
||||
if res_group['state'] in ('pending', 'waiting', 'ready', 'progress'):
|
||||
result_duration_expected[res_group['workcenter_id'][0]] += res_group['duration_expected']
|
||||
for workcenter in self:
|
||||
workcenter.workorder_count = sum(count for state, count in result[workcenter.id].items() if state not in ('done', 'cancel'))
|
||||
workcenter.workorder_pending_count = result[workcenter.id].get('pending', 0)
|
||||
workcenter.workcenter_load = result_duration_expected[workcenter.id]
|
||||
workcenter.workorder_ready_count = result[workcenter.id].get('ready', 0)
|
||||
workcenter.workorder_progress_count = result[workcenter.id].get('progress', 0)
|
||||
workcenter.workorder_late_count = count_data.get(workcenter.id, 0)
|
||||
|
||||
@api.depends('time_ids', 'time_ids.date_end', 'time_ids.loss_type')
|
||||
def _compute_working_state(self):
|
||||
for workcenter in self:
|
||||
# We search for a productivity line associated to this workcenter having no `date_end`.
|
||||
# If we do not find one, the workcenter is not currently being used. If we find one, according
|
||||
# to its `type_loss`, the workcenter is either being used or blocked.
|
||||
time_log = self.env['mrp.workcenter.productivity'].search([
|
||||
('workcenter_id', '=', workcenter.id),
|
||||
('date_end', '=', False)
|
||||
], limit=1)
|
||||
if not time_log:
|
||||
# the workcenter is not being used
|
||||
workcenter.working_state = 'normal'
|
||||
elif time_log.loss_type in ('productive', 'performance'):
|
||||
# the productivity line has a `loss_type` that means the workcenter is being used
|
||||
workcenter.working_state = 'done'
|
||||
else:
|
||||
# the workcenter is blocked
|
||||
workcenter.working_state = 'blocked'
|
||||
|
||||
def _compute_blocked_time(self):
|
||||
# TDE FIXME: productivity loss type should be only losses, probably count other time logs differently ??
|
||||
data = self.env['mrp.workcenter.productivity']._read_group([
|
||||
('date_start', '>=', fields.Datetime.to_string(datetime.now() - relativedelta.relativedelta(months=1))),
|
||||
('workcenter_id', 'in', self.ids),
|
||||
('date_end', '!=', False),
|
||||
('loss_type', '!=', 'productive')],
|
||||
['duration', 'workcenter_id'], ['workcenter_id'], lazy=False)
|
||||
count_data = dict((item['workcenter_id'][0], item['duration']) for item in data)
|
||||
for workcenter in self:
|
||||
workcenter.blocked_time = count_data.get(workcenter.id, 0.0) / 60.0
|
||||
|
||||
def _compute_productive_time(self):
|
||||
# TDE FIXME: productivity loss type should be only losses, probably count other time logs differently
|
||||
data = self.env['mrp.workcenter.productivity']._read_group([
|
||||
('date_start', '>=', fields.Datetime.to_string(datetime.now() - relativedelta.relativedelta(months=1))),
|
||||
('workcenter_id', 'in', self.ids),
|
||||
('date_end', '!=', False),
|
||||
('loss_type', '=', 'productive')],
|
||||
['duration', 'workcenter_id'], ['workcenter_id'], lazy=False)
|
||||
count_data = dict((item['workcenter_id'][0], item['duration']) for item in data)
|
||||
for workcenter in self:
|
||||
workcenter.productive_time = count_data.get(workcenter.id, 0.0) / 60.0
|
||||
|
||||
@api.depends('blocked_time', 'productive_time')
|
||||
def _compute_oee(self):
|
||||
for order in self:
|
||||
if order.productive_time:
|
||||
order.oee = round(order.productive_time * 100.0 / (order.productive_time + order.blocked_time), 2)
|
||||
else:
|
||||
order.oee = 0.0
|
||||
|
||||
def _compute_performance(self):
|
||||
wo_data = self.env['mrp.workorder']._read_group([
|
||||
('date_start', '>=', fields.Datetime.to_string(datetime.now() - relativedelta.relativedelta(months=1))),
|
||||
('workcenter_id', 'in', self.ids),
|
||||
('state', '=', 'done')], ['duration_expected', 'workcenter_id', 'duration'], ['workcenter_id'], lazy=False)
|
||||
duration_expected = dict((data['workcenter_id'][0], data['duration_expected']) for data in wo_data)
|
||||
duration = dict((data['workcenter_id'][0], data['duration']) for data in wo_data)
|
||||
for workcenter in self:
|
||||
if duration.get(workcenter.id):
|
||||
workcenter.performance = 100 * duration_expected.get(workcenter.id, 0.0) / duration[workcenter.id]
|
||||
else:
|
||||
workcenter.performance = 0.0
|
||||
|
||||
@api.constrains('default_capacity')
|
||||
def _check_capacity(self):
|
||||
if any(workcenter.default_capacity <= 0.0 for workcenter in self):
|
||||
raise exceptions.UserError(_('The capacity must be strictly positive.'))
|
||||
|
||||
def unblock(self):
|
||||
self.ensure_one()
|
||||
if self.working_state != 'blocked':
|
||||
raise exceptions.UserError(_("It has already been unblocked."))
|
||||
times = self.env['mrp.workcenter.productivity'].search([('workcenter_id', '=', self.id), ('date_end', '=', False)])
|
||||
times.write({'date_end': datetime.now()})
|
||||
return {'type': 'ir.actions.client', 'tag': 'reload'}
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
# resource_type is 'human' by default. As we are not living in
|
||||
# /r/latestagecapitalism, workcenters are 'material'
|
||||
records = super(MrpWorkcenter, self.with_context(default_resource_type='material')).create(vals_list)
|
||||
return records
|
||||
|
||||
def write(self, vals):
|
||||
if 'company_id' in vals:
|
||||
self.resource_id.company_id = vals['company_id']
|
||||
return super(MrpWorkcenter, self).write(vals)
|
||||
|
||||
def action_show_operations(self):
|
||||
self.ensure_one()
|
||||
action = self.env['ir.actions.actions']._for_xml_id('mrp.mrp_routing_action')
|
||||
action['domain'] = [('workcenter_id', '=', self.id)]
|
||||
action['context'] = {
|
||||
'default_workcenter_id': self.id,
|
||||
}
|
||||
return action
|
||||
|
||||
def action_work_order(self):
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("mrp.action_work_orders")
|
||||
return action
|
||||
|
||||
def _get_unavailability_intervals(self, start_datetime, end_datetime):
|
||||
"""Get the unavailabilities intervals for the workcenters in `self`.
|
||||
|
||||
Return the list of unavailabilities (a tuple of datetimes) indexed
|
||||
by workcenter id.
|
||||
|
||||
:param start_datetime: filter unavailability with only slots after this start_datetime
|
||||
:param end_datetime: filter unavailability with only slots before this end_datetime
|
||||
:rtype: dict
|
||||
"""
|
||||
unavailability_ressources = self.resource_id._get_unavailable_intervals(start_datetime, end_datetime)
|
||||
return {wc.id: unavailability_ressources.get(wc.resource_id.id, []) for wc in self}
|
||||
|
||||
def _get_first_available_slot(self, start_datetime, duration):
|
||||
"""Get the first available interval for the workcenter in `self`.
|
||||
|
||||
The available interval is disjoinct with all other workorders planned on this workcenter, but
|
||||
can overlap the time-off of the related calendar (inverse of the working hours).
|
||||
Return the first available interval (start datetime, end datetime) or,
|
||||
if there is none before 700 days, a tuple error (False, 'error message').
|
||||
|
||||
:param start_datetime: begin the search at this datetime
|
||||
:param duration: minutes needed to make the workorder (float)
|
||||
:rtype: tuple
|
||||
"""
|
||||
self.ensure_one()
|
||||
start_datetime, revert = make_aware(start_datetime)
|
||||
|
||||
resource = self.resource_id
|
||||
get_available_intervals = partial(self.resource_calendar_id._work_intervals_batch, domain=[('time_type', 'in', ['other', 'leave'])], resources=resource, tz=timezone(self.resource_calendar_id.tz))
|
||||
get_workorder_intervals = partial(self.resource_calendar_id._leave_intervals_batch, domain=[('time_type', '=', 'other')], resources=resource, tz=timezone(self.resource_calendar_id.tz))
|
||||
|
||||
remaining = duration
|
||||
start_interval = start_datetime
|
||||
delta = timedelta(days=14)
|
||||
|
||||
for n in range(50): # 50 * 14 = 700 days in advance (hardcoded)
|
||||
dt = start_datetime + delta * n
|
||||
available_intervals = get_available_intervals(dt, dt + delta)[resource.id]
|
||||
workorder_intervals = get_workorder_intervals(dt, dt + delta)[resource.id]
|
||||
for start, stop, dummy in available_intervals:
|
||||
# Shouldn't loop more than 2 times because the available_intervals contains the workorder_intervals
|
||||
# And remaining == duration can only occur at the first loop and at the interval intersection (cannot happen several time because available_intervals > workorder_intervals
|
||||
for _i in range(2):
|
||||
interval_minutes = (stop - start).total_seconds() / 60
|
||||
# If the remaining minutes has never decrease update start_interval
|
||||
if remaining == duration:
|
||||
start_interval = start
|
||||
# If there is a overlap between the possible available interval and a others WO
|
||||
if Intervals([(start_interval, start + timedelta(minutes=min(remaining, interval_minutes)), dummy)]) & workorder_intervals:
|
||||
remaining = duration
|
||||
elif float_compare(interval_minutes, remaining, precision_digits=3) >= 0:
|
||||
return revert(start_interval), revert(start + timedelta(minutes=remaining))
|
||||
else:
|
||||
# Decrease a part of the remaining duration
|
||||
remaining -= interval_minutes
|
||||
# Go to the next available interval because the possible current interval duration has been used
|
||||
break
|
||||
return False, 'Not available slot 700 days after the planned start'
|
||||
|
||||
def action_archive(self):
|
||||
res = super().action_archive()
|
||||
filtered_workcenters = ", ".join(workcenter.name for workcenter in self.filtered('routing_line_ids'))
|
||||
if filtered_workcenters:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _("Note that archived work center(s): '%s' is/are still linked to active Bill of Materials, which means that operations can still be planned on it/them. "
|
||||
"To prevent this, deletion of the work center is recommended instead.", filtered_workcenters),
|
||||
'type': 'warning',
|
||||
'sticky': True, #True/False will display for few seconds if false
|
||||
'next': {'type': 'ir.actions.act_window_close'},
|
||||
},
|
||||
}
|
||||
return res
|
||||
|
||||
def _get_capacity(self, product):
|
||||
product_capacity = self.capacity_ids.filtered(lambda capacity: capacity.product_id == product)
|
||||
return product_capacity.capacity if product_capacity else self.default_capacity
|
||||
|
||||
def _get_expected_duration(self, product_id):
|
||||
"""Compute the expected duration when using this work-center
|
||||
Always include workcenter startup time and clean-up time.
|
||||
In case there are specific capacities defined in the workcenter
|
||||
that matches the product we are producing. Add the extra-time.
|
||||
"""
|
||||
capacity = self.capacity_ids.filtered(lambda p: p.product_id == product_id)
|
||||
return self.time_start + self.time_stop + (capacity.time_start + capacity.time_stop if capacity else 0.0)
|
||||
|
||||
|
||||
class WorkcenterTag(models.Model):
|
||||
_name = 'mrp.workcenter.tag'
|
||||
_description = 'Add tag for the workcenter'
|
||||
_order = 'name'
|
||||
|
||||
def _get_default_color(self):
|
||||
return randint(1, 11)
|
||||
|
||||
name = fields.Char("Tag Name", required=True)
|
||||
color = fields.Integer("Color Index", default=_get_default_color)
|
||||
|
||||
_sql_constraints = [
|
||||
('tag_name_unique', 'unique(name)',
|
||||
'The tag name must be unique.'),
|
||||
]
|
||||
|
||||
|
||||
class MrpWorkcenterProductivityLossType(models.Model):
|
||||
_name = "mrp.workcenter.productivity.loss.type"
|
||||
_description = 'MRP Workorder productivity losses'
|
||||
_rec_name = 'loss_type'
|
||||
|
||||
@api.depends('loss_type')
|
||||
def name_get(self):
|
||||
""" As 'category' field in form view is a Many2one, its value will be in
|
||||
lower case. In order to display its value capitalized 'name_get' is
|
||||
overrided.
|
||||
"""
|
||||
result = []
|
||||
for rec in self:
|
||||
result.append((rec.id, rec.loss_type.title()))
|
||||
return result
|
||||
|
||||
loss_type = fields.Selection([
|
||||
('availability', 'Availability'),
|
||||
('performance', 'Performance'),
|
||||
('quality', 'Quality'),
|
||||
('productive', 'Productive')], string='Category', default='availability', required=True)
|
||||
|
||||
|
||||
class MrpWorkcenterProductivityLoss(models.Model):
|
||||
_name = "mrp.workcenter.productivity.loss"
|
||||
_description = "Workcenter Productivity Losses"
|
||||
_order = "sequence, id"
|
||||
|
||||
name = fields.Char('Blocking Reason', required=True)
|
||||
sequence = fields.Integer('Sequence', default=1)
|
||||
manual = fields.Boolean('Is a Blocking Reason', default=True)
|
||||
loss_id = fields.Many2one('mrp.workcenter.productivity.loss.type', domain=([('loss_type', 'in', ['quality', 'availability'])]), string='Category')
|
||||
loss_type = fields.Selection(string='Effectiveness Category', related='loss_id.loss_type', store=True, readonly=False)
|
||||
|
||||
def _convert_to_duration(self, date_start, date_stop, workcenter=False):
|
||||
""" Convert a date range into a duration in minutes.
|
||||
If the productivity type is not from an employee (extra hours are allow)
|
||||
and the workcenter has a calendar, convert the dates into a duration based on
|
||||
working hours.
|
||||
"""
|
||||
duration = 0
|
||||
for productivity_loss in self:
|
||||
if (productivity_loss.loss_type not in ('productive', 'performance')) and workcenter and workcenter.resource_calendar_id:
|
||||
r = workcenter._get_work_days_data_batch(date_start, date_stop)[workcenter.id]['hours']
|
||||
duration = max(duration, r * 60)
|
||||
else:
|
||||
duration = max(duration, (date_stop - date_start).total_seconds() / 60.0)
|
||||
return round(duration, 2)
|
||||
|
||||
class MrpWorkcenterProductivity(models.Model):
|
||||
_name = "mrp.workcenter.productivity"
|
||||
_description = "Workcenter Productivity Log"
|
||||
_order = "id desc"
|
||||
_rec_name = "loss_id"
|
||||
_check_company_auto = True
|
||||
|
||||
def _get_default_company_id(self):
|
||||
company_id = False
|
||||
if self.env.context.get('default_company_id'):
|
||||
company_id = self.env.context['default_company_id']
|
||||
if not company_id and self.env.context.get('default_workorder_id'):
|
||||
workorder = self.env['mrp.workorder'].browse(self.env.context['default_workorder_id'])
|
||||
company_id = workorder.company_id
|
||||
if not company_id and self.env.context.get('default_workcenter_id'):
|
||||
workcenter = self.env['mrp.workcenter'].browse(self.env.context['default_workcenter_id'])
|
||||
company_id = workcenter.company_id
|
||||
if not company_id:
|
||||
company_id = self.env.company
|
||||
return company_id
|
||||
|
||||
production_id = fields.Many2one('mrp.production', string='Manufacturing Order', related='workorder_id.production_id', readonly=True)
|
||||
workcenter_id = fields.Many2one('mrp.workcenter', "Work Center", required=True, check_company=True, index=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', required=True, index=True,
|
||||
default=lambda self: self._get_default_company_id())
|
||||
workorder_id = fields.Many2one('mrp.workorder', 'Work Order', check_company=True, index=True)
|
||||
user_id = fields.Many2one(
|
||||
'res.users', "User",
|
||||
default=lambda self: self.env.uid)
|
||||
loss_id = fields.Many2one(
|
||||
'mrp.workcenter.productivity.loss', "Loss Reason",
|
||||
ondelete='restrict', required=True)
|
||||
loss_type = fields.Selection(
|
||||
string="Effectiveness", related='loss_id.loss_type', store=True, readonly=False)
|
||||
description = fields.Text('Description')
|
||||
date_start = fields.Datetime('Start Date', default=fields.Datetime.now, required=True)
|
||||
date_end = fields.Datetime('End Date')
|
||||
duration = fields.Float('Duration', compute='_compute_duration', store=True)
|
||||
|
||||
@api.depends('date_end', 'date_start')
|
||||
def _compute_duration(self):
|
||||
for blocktime in self:
|
||||
if blocktime.date_start and blocktime.date_end:
|
||||
blocktime.duration = blocktime.loss_id._convert_to_duration(blocktime.date_start.replace(microsecond=0), blocktime.date_end.replace(microsecond=0), blocktime.workcenter_id)
|
||||
else:
|
||||
blocktime.duration = 0.0
|
||||
|
||||
@api.constrains('workorder_id')
|
||||
def _check_open_time_ids(self):
|
||||
for workorder in self.workorder_id:
|
||||
open_time_ids_by_user = self.env["mrp.workcenter.productivity"].read_group([("id", "in", workorder.time_ids.ids), ("date_end", "=", False)], ["user_id", "open_time_ids_count:count(id)"], ["user_id"])
|
||||
if any(data["open_time_ids_count"] > 1 for data in open_time_ids_by_user):
|
||||
raise ValidationError(_('The Workorder (%s) cannot be started twice!', workorder.display_name))
|
||||
|
||||
def button_block(self):
|
||||
self.ensure_one()
|
||||
self.workcenter_id.order_ids.end_all()
|
||||
|
||||
def _close(self):
|
||||
underperformance_timers = self.env['mrp.workcenter.productivity']
|
||||
for timer in self:
|
||||
wo = timer.workorder_id
|
||||
timer.write({'date_end': fields.Datetime.now()})
|
||||
if wo.duration > wo.duration_expected:
|
||||
productive_date_end = timer.date_end - relativedelta.relativedelta(minutes=wo.duration - wo.duration_expected)
|
||||
if productive_date_end <= timer.date_start:
|
||||
underperformance_timers |= timer
|
||||
else:
|
||||
underperformance_timers |= timer.copy({'date_start': productive_date_end})
|
||||
timer.write({'date_end': productive_date_end})
|
||||
if underperformance_timers:
|
||||
underperformance_type = self.env['mrp.workcenter.productivity.loss'].search([('loss_type', '=', 'performance')], limit=1)
|
||||
if not underperformance_type:
|
||||
raise UserError(_("You need to define at least one unactive productivity loss in the category 'Performance'. Create one from the Manufacturing app, menu: Configuration / Productivity Losses."))
|
||||
underperformance_timers.write({'loss_id': underperformance_type.id})
|
||||
|
||||
|
||||
class MrpWorkCenterCapacity(models.Model):
|
||||
_name = 'mrp.workcenter.capacity'
|
||||
_description = 'Work Center Capacity'
|
||||
_check_company_auto = True
|
||||
|
||||
workcenter_id = fields.Many2one('mrp.workcenter', string='Work Center', required=True)
|
||||
product_id = fields.Many2one('product.product', string='Product', required=True)
|
||||
product_uom_id = fields.Many2one('uom.uom', string='Product UoM', related='product_id.uom_id')
|
||||
capacity = fields.Float('Capacity', default=1.0, help="Number of pieces that can be produced in parallel for this product.")
|
||||
time_start = fields.Float('Setup Time (minutes)', help="Additional time in minutes for the setup.")
|
||||
time_stop = fields.Float('Cleanup Time (minutes)', help="Additional time in minutes for the cleaning.")
|
||||
|
||||
_sql_constraints = [
|
||||
('positive_capacity', 'CHECK(capacity > 0)', 'Capacity should be a positive number.'),
|
||||
('unique_product', 'UNIQUE(workcenter_id, product_id)', 'Product capacity should be unique for each workcenter.'),
|
||||
]
|
||||
911
odoo-bringout-oca-ocb-mrp/mrp/models/mrp_workorder.py
Normal file
911
odoo-bringout-oca-ocb-mrp/mrp/models/mrp_workorder.py
Normal file
|
|
@ -0,0 +1,911 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from collections import defaultdict
|
||||
import json
|
||||
|
||||
from odoo import api, fields, models, _, SUPERUSER_ID
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
from odoo.osv import expression
|
||||
from odoo.tools import float_compare, float_round, format_datetime
|
||||
|
||||
|
||||
class MrpWorkorder(models.Model):
|
||||
_name = 'mrp.workorder'
|
||||
_description = 'Work Order'
|
||||
|
||||
def _read_group_workcenter_id(self, workcenters, domain, order):
|
||||
workcenter_ids = self.env.context.get('default_workcenter_id')
|
||||
if not workcenter_ids:
|
||||
workcenter_ids = workcenters._search([], order=order, access_rights_uid=SUPERUSER_ID)
|
||||
return workcenters.browse(workcenter_ids)
|
||||
|
||||
name = fields.Char(
|
||||
'Work Order', required=True,
|
||||
states={'done': [('readonly', True)], 'cancel': [('readonly', True)]})
|
||||
workcenter_id = fields.Many2one(
|
||||
'mrp.workcenter', 'Work Center', required=True,
|
||||
states={'done': [('readonly', True)], 'cancel': [('readonly', True)], 'progress': [('readonly', True)]},
|
||||
group_expand='_read_group_workcenter_id', check_company=True)
|
||||
working_state = fields.Selection(
|
||||
string='Workcenter Status', related='workcenter_id.working_state') # technical: used in views only
|
||||
product_id = fields.Many2one(related='production_id.product_id', readonly=True, store=True, check_company=True)
|
||||
product_tracking = fields.Selection(related="product_id.tracking")
|
||||
product_uom_id = fields.Many2one('uom.uom', 'Unit of Measure', required=True, readonly=True)
|
||||
production_id = fields.Many2one('mrp.production', 'Manufacturing Order', required=True, check_company=True, readonly=True)
|
||||
production_availability = fields.Selection(
|
||||
string='Stock Availability', readonly=True,
|
||||
related='production_id.reservation_state', store=True) # Technical: used in views and domains only
|
||||
production_state = fields.Selection(
|
||||
string='Production State', readonly=True,
|
||||
related='production_id.state') # Technical: used in views only
|
||||
production_bom_id = fields.Many2one('mrp.bom', related='production_id.bom_id')
|
||||
qty_production = fields.Float('Original Production Quantity', readonly=True, related='production_id.product_qty')
|
||||
company_id = fields.Many2one(related='production_id.company_id')
|
||||
qty_producing = fields.Float(
|
||||
compute='_compute_qty_producing', inverse='_set_qty_producing',
|
||||
string='Currently Produced Quantity', digits='Product Unit of Measure')
|
||||
qty_remaining = fields.Float('Quantity To Be Produced', compute='_compute_qty_remaining', digits='Product Unit of Measure')
|
||||
qty_produced = fields.Float(
|
||||
'Quantity', default=0.0,
|
||||
readonly=True,
|
||||
digits='Product Unit of Measure',
|
||||
copy=False,
|
||||
help="The number of products already handled by this work order")
|
||||
is_produced = fields.Boolean(string="Has Been Produced",
|
||||
compute='_compute_is_produced')
|
||||
state = fields.Selection([
|
||||
('pending', 'Waiting for another WO'),
|
||||
('waiting', 'Waiting for components'),
|
||||
('ready', 'Ready'),
|
||||
('progress', 'In Progress'),
|
||||
('done', 'Finished'),
|
||||
('cancel', 'Cancelled')], string='Status',
|
||||
compute='_compute_state', store=True,
|
||||
default='pending', copy=False, readonly=True, recursive=True, index=True)
|
||||
leave_id = fields.Many2one(
|
||||
'resource.calendar.leaves',
|
||||
help='Slot into workcenter calendar once planned',
|
||||
check_company=True, copy=False)
|
||||
date_planned_start = fields.Datetime(
|
||||
'Scheduled Start Date',
|
||||
compute='_compute_dates_planned',
|
||||
inverse='_set_dates_planned',
|
||||
states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
|
||||
store=True, copy=False)
|
||||
date_planned_finished = fields.Datetime(
|
||||
'Scheduled End Date',
|
||||
compute='_compute_dates_planned',
|
||||
inverse='_set_dates_planned',
|
||||
states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
|
||||
store=True, copy=False)
|
||||
date_start = fields.Datetime(
|
||||
'Start Date', copy=False,
|
||||
states={'done': [('readonly', True)], 'cancel': [('readonly', True)]})
|
||||
date_finished = fields.Datetime(
|
||||
'End Date', copy=False,
|
||||
states={'done': [('readonly', True)], 'cancel': [('readonly', True)]})
|
||||
|
||||
duration_expected = fields.Float(
|
||||
'Expected Duration', digits=(16, 2), compute='_compute_duration_expected',
|
||||
states={'done': [('readonly', True)], 'cancel': [('readonly', True)]},
|
||||
readonly=False, store=True) # in minutes
|
||||
duration = fields.Float(
|
||||
'Real Duration', compute='_compute_duration', inverse='_set_duration',
|
||||
readonly=False, store=True, copy=False)
|
||||
duration_unit = fields.Float(
|
||||
'Duration Per Unit', compute='_compute_duration',
|
||||
group_operator="avg", readonly=True, store=True)
|
||||
duration_percent = fields.Integer(
|
||||
'Duration Deviation (%)', compute='_compute_duration',
|
||||
group_operator="avg", readonly=True, store=True)
|
||||
progress = fields.Float('Progress Done (%)', digits=(16, 2), compute='_compute_progress')
|
||||
|
||||
operation_id = fields.Many2one(
|
||||
'mrp.routing.workcenter', 'Operation', check_company=True)
|
||||
# Should be used differently as BoM can change in the meantime
|
||||
worksheet = fields.Binary(
|
||||
'Worksheet', related='operation_id.worksheet', readonly=True)
|
||||
worksheet_type = fields.Selection(
|
||||
string='Worksheet Type', related='operation_id.worksheet_type', readonly=True)
|
||||
worksheet_google_slide = fields.Char(
|
||||
'Worksheet URL', related='operation_id.worksheet_google_slide', readonly=True)
|
||||
operation_note = fields.Html("Description", related='operation_id.note', readonly=True)
|
||||
move_raw_ids = fields.One2many(
|
||||
'stock.move', 'workorder_id', 'Raw Moves',
|
||||
domain=[('raw_material_production_id', '!=', False), ('production_id', '=', False)])
|
||||
move_finished_ids = fields.One2many(
|
||||
'stock.move', 'workorder_id', 'Finished Moves',
|
||||
domain=[('raw_material_production_id', '=', False), ('production_id', '!=', False)])
|
||||
move_line_ids = fields.One2many(
|
||||
'stock.move.line', 'workorder_id', 'Moves to Track',
|
||||
help="Inventory moves for which you must scan a lot number at this work order")
|
||||
finished_lot_id = fields.Many2one(
|
||||
'stock.lot', string='Lot/Serial Number', compute='_compute_finished_lot_id',
|
||||
inverse='_set_finished_lot_id', domain="[('product_id', '=', product_id), ('company_id', '=', company_id)]",
|
||||
check_company=True, search='_search_finished_lot_id')
|
||||
time_ids = fields.One2many(
|
||||
'mrp.workcenter.productivity', 'workorder_id', copy=False)
|
||||
is_user_working = fields.Boolean(
|
||||
'Is the Current User Working', compute='_compute_working_users') # technical: is the current user working
|
||||
working_user_ids = fields.One2many('res.users', string='Working user on this work order.', compute='_compute_working_users')
|
||||
last_working_user_id = fields.One2many('res.users', string='Last user that worked on this work order.', compute='_compute_working_users')
|
||||
costs_hour = fields.Float(
|
||||
string='Cost per hour',
|
||||
default=0.0, group_operator="avg")
|
||||
# Technical field to store the hourly cost of workcenter at time of work order completion (i.e. to keep a consistent cost).',
|
||||
|
||||
scrap_ids = fields.One2many('stock.scrap', 'workorder_id')
|
||||
scrap_count = fields.Integer(compute='_compute_scrap_move_count', string='Scrap Move')
|
||||
production_date = fields.Datetime('Production Date', related='production_id.date_planned_start', store=True)
|
||||
json_popover = fields.Char('Popover Data JSON', compute='_compute_json_popover')
|
||||
show_json_popover = fields.Boolean('Show Popover?', compute='_compute_json_popover')
|
||||
consumption = fields.Selection(related='production_id.consumption')
|
||||
qty_reported_from_previous_wo = fields.Float('Carried Quantity', digits='Product Unit of Measure', copy=False,
|
||||
help="The quantity already produced awaiting allocation in the backorders chain.")
|
||||
is_planned = fields.Boolean(related='production_id.is_planned')
|
||||
allow_workorder_dependencies = fields.Boolean(related='production_id.allow_workorder_dependencies')
|
||||
blocked_by_workorder_ids = fields.Many2many('mrp.workorder', relation="mrp_workorder_dependencies_rel",
|
||||
column1="workorder_id", column2="blocked_by_id", string="Blocked By",
|
||||
domain="[('allow_workorder_dependencies', '=', True), ('id', '!=', id), ('production_id', '=', production_id)]",
|
||||
copy=False)
|
||||
needed_by_workorder_ids = fields.Many2many('mrp.workorder', relation="mrp_workorder_dependencies_rel",
|
||||
column1="blocked_by_id", column2="workorder_id", string="Blocks",
|
||||
domain="[('allow_workorder_dependencies', '=', True), ('id', '!=', id), ('production_id', '=', production_id)]",
|
||||
copy=False)
|
||||
|
||||
@api.depends('production_availability', 'blocked_by_workorder_ids.state')
|
||||
def _compute_state(self):
|
||||
# Force to compute the production_availability right away.
|
||||
# It is a trick to force that the state of workorder is computed at the end of the
|
||||
# cyclic depends with the mo.state, mo.reservation_state and wo.state and avoid recursion error
|
||||
self.mapped('production_availability')
|
||||
for workorder in self:
|
||||
if workorder.state == 'pending':
|
||||
if all([wo.state in ('done', 'cancel') for wo in workorder.blocked_by_workorder_ids]):
|
||||
workorder.state = 'ready' if workorder.production_availability == 'assigned' else 'waiting'
|
||||
continue
|
||||
if workorder.state not in ('waiting', 'ready'):
|
||||
continue
|
||||
if not all([wo.state in ('done', 'cancel') for wo in workorder.blocked_by_workorder_ids]):
|
||||
workorder.state = 'pending'
|
||||
continue
|
||||
if workorder.production_availability not in ('waiting', 'confirmed', 'assigned'):
|
||||
continue
|
||||
if workorder.production_availability == 'assigned' and workorder.state == 'waiting':
|
||||
workorder.state = 'ready'
|
||||
elif workorder.production_availability != 'assigned' and workorder.state == 'ready':
|
||||
workorder.state = 'waiting'
|
||||
|
||||
@api.depends('production_state', 'date_planned_start', 'date_planned_finished')
|
||||
def _compute_json_popover(self):
|
||||
if self.ids:
|
||||
conflicted_dict = self._get_conflicted_workorder_ids()
|
||||
for wo in self:
|
||||
infos = []
|
||||
if not wo.date_planned_start or not wo.date_planned_finished or not wo.ids:
|
||||
wo.show_json_popover = False
|
||||
wo.json_popover = False
|
||||
continue
|
||||
if wo.state in ('pending', 'waiting', 'ready'):
|
||||
previous_wos = wo.blocked_by_workorder_ids
|
||||
prev_start = min([workorder.date_planned_start for workorder in previous_wos]) if previous_wos else False
|
||||
prev_finished = max([workorder.date_planned_finished for workorder in previous_wos]) if previous_wos else False
|
||||
if wo.state == 'pending' and prev_start and not (prev_start > wo.date_planned_start):
|
||||
infos.append({
|
||||
'color': 'text-primary',
|
||||
'msg': _("Waiting the previous work order, planned from %(start)s to %(end)s",
|
||||
start=format_datetime(self.env, prev_start, dt_format=False),
|
||||
end=format_datetime(self.env, prev_finished, dt_format=False))
|
||||
})
|
||||
if wo.date_planned_finished < fields.Datetime.now():
|
||||
infos.append({
|
||||
'color': 'text-warning',
|
||||
'msg': _("The work order should have already been processed.")
|
||||
})
|
||||
if prev_start and prev_start > wo.date_planned_start:
|
||||
infos.append({
|
||||
'color': 'text-danger',
|
||||
'msg': _("Scheduled before the previous work order, planned from %(start)s to %(end)s",
|
||||
start=format_datetime(self.env, prev_start, dt_format=False),
|
||||
end=format_datetime(self.env, prev_finished, dt_format=False))
|
||||
})
|
||||
if conflicted_dict.get(wo.id):
|
||||
infos.append({
|
||||
'color': 'text-danger',
|
||||
'msg': _("Planned at the same time as other workorder(s) at %s", wo.workcenter_id.display_name)
|
||||
})
|
||||
color_icon = infos and infos[-1]['color'] or False
|
||||
wo.show_json_popover = bool(color_icon)
|
||||
wo.json_popover = json.dumps({
|
||||
'popoverTemplate': 'mrp.workorderPopover',
|
||||
'infos': infos,
|
||||
'color': color_icon,
|
||||
'icon': 'fa-exclamation-triangle' if color_icon in ['text-warning', 'text-danger'] else 'fa-info-circle',
|
||||
'replan': color_icon not in [False, 'text-primary']
|
||||
})
|
||||
|
||||
@api.depends('production_id.lot_producing_id')
|
||||
def _compute_finished_lot_id(self):
|
||||
for workorder in self:
|
||||
workorder.finished_lot_id = workorder.production_id.lot_producing_id
|
||||
|
||||
def _search_finished_lot_id(self, operator, value):
|
||||
return [('production_id.lot_producing_id', operator, value)]
|
||||
|
||||
def _set_finished_lot_id(self):
|
||||
for workorder in self:
|
||||
workorder.production_id.lot_producing_id = workorder.finished_lot_id
|
||||
|
||||
@api.depends('production_id.qty_producing')
|
||||
def _compute_qty_producing(self):
|
||||
for workorder in self:
|
||||
workorder.qty_producing = workorder.production_id.qty_producing
|
||||
|
||||
def _set_qty_producing(self):
|
||||
for workorder in self:
|
||||
if workorder.qty_producing != 0 and workorder.production_id.qty_producing != workorder.qty_producing:
|
||||
workorder.production_id.qty_producing = workorder.qty_producing
|
||||
workorder.production_id._set_qty_producing()
|
||||
|
||||
# Both `date_planned_start` and `date_planned_finished` are related fields on `leave_id`. Let's say
|
||||
# we slide a workorder on a gantt view, a single call to write is made with both
|
||||
# fields Changes. As the ORM doesn't batch the write on related fields and instead
|
||||
# makes multiple call, the constraint check_dates() is raised.
|
||||
# That's why the compute and set methods are needed. to ensure the dates are updated
|
||||
# in the same time.
|
||||
@api.depends('leave_id')
|
||||
def _compute_dates_planned(self):
|
||||
for workorder in self:
|
||||
workorder.date_planned_start = workorder.leave_id.date_from
|
||||
workorder.date_planned_finished = workorder.leave_id.date_to
|
||||
|
||||
def _set_dates_planned(self):
|
||||
if not self[0].date_planned_start:
|
||||
if not self.leave_id:
|
||||
return
|
||||
raise UserError(_("It is not possible to unplan one single Work Order. "
|
||||
"You should unplan the Manufacturing Order instead in order to unplan all the linked operations."))
|
||||
date_from = self[0].date_planned_start
|
||||
for wo in self:
|
||||
if not wo.date_planned_finished:
|
||||
wo.date_planned_finished = wo._calculate_date_planned_finished()
|
||||
date_to = self[0].date_planned_finished
|
||||
to_write = self.env['mrp.workorder']
|
||||
for wo in self.sudo():
|
||||
if wo.leave_id:
|
||||
to_write |= wo
|
||||
else:
|
||||
wo.leave_id = wo.env['resource.calendar.leaves'].create({
|
||||
'name': wo.display_name,
|
||||
'calendar_id': wo.workcenter_id.resource_calendar_id.id,
|
||||
'date_from': date_from,
|
||||
'date_to': date_to,
|
||||
'resource_id': wo.workcenter_id.resource_id.id,
|
||||
'time_type': 'other',
|
||||
})
|
||||
to_write.leave_id.write({
|
||||
'date_from': date_from,
|
||||
'date_to': date_to,
|
||||
})
|
||||
|
||||
@api.constrains('blocked_by_workorder_ids')
|
||||
def _check_no_cyclic_dependencies(self):
|
||||
if not self._check_m2m_recursion('blocked_by_workorder_ids'):
|
||||
raise ValidationError(_("You cannot create cyclic dependency."))
|
||||
|
||||
def name_get(self):
|
||||
res = []
|
||||
for wo in self:
|
||||
if len(wo.production_id.workorder_ids) == 1:
|
||||
res.append((wo.id, "%s - %s - %s" % (wo.production_id.name, wo.product_id.name, wo.name)))
|
||||
else:
|
||||
res.append((wo.id, "%s - %s - %s - %s" % (wo.production_id.workorder_ids.ids.index(wo._origin.id) + 1, wo.production_id.name, wo.product_id.name, wo.name)))
|
||||
return res
|
||||
|
||||
def unlink(self):
|
||||
# Removes references to workorder to avoid Validation Error
|
||||
(self.mapped('move_raw_ids') | self.mapped('move_finished_ids')).write({'workorder_id': False})
|
||||
self.mapped('leave_id').unlink()
|
||||
mo_dirty = self.production_id.filtered(lambda mo: mo.state in ("confirmed", "progress", "to_close"))
|
||||
|
||||
for workorder in self:
|
||||
workorder.blocked_by_workorder_ids.needed_by_workorder_ids = workorder.needed_by_workorder_ids
|
||||
res = super().unlink()
|
||||
# We need to go through `_action_confirm` for all workorders of the current productions to
|
||||
# make sure the links between them are correct (`next_work_order_id` could be obsolete now).
|
||||
mo_dirty.workorder_ids._action_confirm()
|
||||
return res
|
||||
|
||||
@api.depends('production_id.product_qty', 'qty_produced', 'production_id.product_uom_id')
|
||||
def _compute_is_produced(self):
|
||||
self.is_produced = False
|
||||
for order in self.filtered(lambda p: p.production_id and p.production_id.product_uom_id):
|
||||
rounding = order.production_id.product_uom_id.rounding
|
||||
order.is_produced = float_compare(order.qty_produced, order.production_id.product_qty, precision_rounding=rounding) >= 0
|
||||
|
||||
@api.depends('operation_id', 'workcenter_id', 'qty_producing', 'qty_production')
|
||||
def _compute_duration_expected(self):
|
||||
for workorder in self:
|
||||
# Recompute the duration expected if the qty_producing has been changed:
|
||||
# compare with the origin record if it happens during an onchange
|
||||
if workorder.state not in ['done', 'cancel'] and (workorder.qty_producing != workorder.qty_production
|
||||
or (workorder._origin != workorder and workorder._origin.qty_producing and workorder.qty_producing != workorder._origin.qty_producing)):
|
||||
workorder.duration_expected = workorder._get_duration_expected()
|
||||
|
||||
@api.depends('time_ids.duration', 'qty_produced')
|
||||
def _compute_duration(self):
|
||||
for order in self:
|
||||
order.duration = sum(order.time_ids.mapped('duration'))
|
||||
order.duration_unit = round(order.duration / max(order.qty_produced, 1), 2) # rounding 2 because it is a time
|
||||
if order.duration_expected:
|
||||
order.duration_percent = max(-2147483648, min(2147483647, 100 * (order.duration_expected - order.duration) / order.duration_expected))
|
||||
else:
|
||||
order.duration_percent = 0
|
||||
|
||||
def _set_duration(self):
|
||||
|
||||
def _float_duration_to_second(duration):
|
||||
minutes = duration // 1
|
||||
seconds = (duration % 1) * 60
|
||||
return minutes * 60 + seconds
|
||||
|
||||
for order in self:
|
||||
old_order_duration = sum(order.time_ids.mapped('duration'))
|
||||
new_order_duration = order.duration
|
||||
if new_order_duration == old_order_duration:
|
||||
continue
|
||||
|
||||
delta_duration = new_order_duration - old_order_duration
|
||||
|
||||
if delta_duration > 0:
|
||||
enddate = datetime.now()
|
||||
date_start = enddate - timedelta(seconds=_float_duration_to_second(delta_duration))
|
||||
if order.duration_expected >= new_order_duration or old_order_duration >= order.duration_expected:
|
||||
# either only productive or only performance (i.e. reduced speed) time respectively
|
||||
self.env['mrp.workcenter.productivity'].create(
|
||||
order._prepare_timeline_vals(new_order_duration, date_start, enddate)
|
||||
)
|
||||
else:
|
||||
# split between productive and performance (i.e. reduced speed) times
|
||||
maxdate = fields.Datetime.from_string(enddate) - relativedelta(minutes=new_order_duration - order.duration_expected)
|
||||
self.env['mrp.workcenter.productivity'].create([
|
||||
order._prepare_timeline_vals(order.duration_expected, date_start, maxdate),
|
||||
order._prepare_timeline_vals(new_order_duration, maxdate, enddate)
|
||||
])
|
||||
else:
|
||||
duration_to_remove = abs(delta_duration)
|
||||
timelines_to_unlink = self.env['mrp.workcenter.productivity']
|
||||
for timeline in order.time_ids.sorted():
|
||||
if duration_to_remove <= 0.0:
|
||||
break
|
||||
if timeline.duration <= duration_to_remove:
|
||||
duration_to_remove -= timeline.duration
|
||||
timelines_to_unlink |= timeline
|
||||
else:
|
||||
new_time_line_duration = timeline.duration - duration_to_remove
|
||||
timeline.date_start = timeline.date_end - timedelta(seconds=_float_duration_to_second(new_time_line_duration))
|
||||
break
|
||||
timelines_to_unlink.unlink()
|
||||
|
||||
@api.depends('duration', 'duration_expected', 'state')
|
||||
def _compute_progress(self):
|
||||
for order in self:
|
||||
if order.state == 'done':
|
||||
order.progress = 100
|
||||
elif order.duration_expected:
|
||||
order.progress = order.duration * 100 / order.duration_expected
|
||||
else:
|
||||
order.progress = 0
|
||||
|
||||
def _compute_working_users(self):
|
||||
""" Checks whether the current user is working, all the users currently working and the last user that worked. """
|
||||
for order in self:
|
||||
order.working_user_ids = [(4, order.id) for order in order.time_ids.filtered(lambda time: not time.date_end).sorted('date_start').mapped('user_id')]
|
||||
if order.working_user_ids:
|
||||
order.last_working_user_id = order.working_user_ids[-1]
|
||||
elif order.time_ids:
|
||||
order.last_working_user_id = order.time_ids.filtered('date_end').sorted('date_end')[-1].user_id if order.time_ids.filtered('date_end') else order.time_ids[-1].user_id
|
||||
else:
|
||||
order.last_working_user_id = False
|
||||
if order.time_ids.filtered(lambda x: (x.user_id.id == self.env.user.id) and (not x.date_end) and (x.loss_type in ('productive', 'performance'))):
|
||||
order.is_user_working = True
|
||||
else:
|
||||
order.is_user_working = False
|
||||
|
||||
def _compute_scrap_move_count(self):
|
||||
data = self.env['stock.scrap']._read_group([('workorder_id', 'in', self.ids)], ['workorder_id'], ['workorder_id'])
|
||||
count_data = dict((item['workorder_id'][0], item['workorder_id_count']) for item in data)
|
||||
for workorder in self:
|
||||
workorder.scrap_count = count_data.get(workorder.id, 0)
|
||||
|
||||
@api.onchange('operation_id')
|
||||
def _onchange_operation_id(self):
|
||||
if self.operation_id:
|
||||
self.name = self.operation_id.name
|
||||
self.workcenter_id = self.operation_id.workcenter_id.id
|
||||
|
||||
@api.onchange('date_planned_start', 'duration_expected', 'workcenter_id')
|
||||
def _onchange_date_planned_start(self):
|
||||
if self.date_planned_start and self.workcenter_id:
|
||||
self.date_planned_finished = self._calculate_date_planned_finished()
|
||||
|
||||
def _calculate_date_planned_finished(self, date_planned_start=False):
|
||||
return self.workcenter_id.resource_calendar_id.plan_hours(
|
||||
self.duration_expected / 60.0, date_planned_start or self.date_planned_start,
|
||||
compute_leaves=True, domain=[('time_type', 'in', ['leave', 'other'])]
|
||||
)
|
||||
|
||||
@api.onchange('date_planned_finished')
|
||||
def _onchange_date_planned_finished(self):
|
||||
if self.date_planned_start and self.date_planned_finished and self.workcenter_id:
|
||||
self.duration_expected = self._calculate_duration_expected()
|
||||
|
||||
if not self.date_planned_finished and self.date_planned_start:
|
||||
raise UserError(_("It is not possible to unplan one single Work Order. "
|
||||
"You should unplan the Manufacturing Order instead in order to unplan all the linked operations."))
|
||||
|
||||
def _calculate_duration_expected(self, date_planned_start=False, date_planned_finished=False):
|
||||
interval = self.workcenter_id.resource_calendar_id.get_work_duration_data(
|
||||
date_planned_start or self.date_planned_start, date_planned_finished or self.date_planned_finished,
|
||||
domain=[('time_type', 'in', ['leave', 'other'])]
|
||||
)
|
||||
return interval['hours'] * 60
|
||||
|
||||
@api.onchange('finished_lot_id')
|
||||
def _onchange_finished_lot_id(self):
|
||||
if self.production_id:
|
||||
res = self.production_id._can_produce_serial_number(sn=self.finished_lot_id)
|
||||
if res is not True:
|
||||
return res
|
||||
|
||||
def write(self, values):
|
||||
if 'production_id' in values and any(values['production_id'] != w.production_id.id for w in self):
|
||||
raise UserError(_('You cannot link this work order to another manufacturing order.'))
|
||||
if 'workcenter_id' in values:
|
||||
for workorder in self:
|
||||
if workorder.workcenter_id.id != values['workcenter_id']:
|
||||
if workorder.state in ('progress', 'done', 'cancel'):
|
||||
raise UserError(_('You cannot change the workcenter of a work order that is in progress or done.'))
|
||||
workorder.leave_id.resource_id = self.env['mrp.workcenter'].browse(values['workcenter_id']).resource_id
|
||||
if 'date_planned_start' in values or 'date_planned_finished' in values:
|
||||
for workorder in self:
|
||||
start_date = fields.Datetime.to_datetime(values.get('date_planned_start', workorder.date_planned_start))
|
||||
end_date = fields.Datetime.to_datetime(values.get('date_planned_finished', workorder.date_planned_finished))
|
||||
if start_date and end_date and start_date > end_date:
|
||||
raise UserError(_('The planned end date of the work order cannot be prior to the planned start date, please correct this to save the work order.'))
|
||||
if 'duration_expected' not in values and not self.env.context.get('bypass_duration_calculation'):
|
||||
if values.get('date_planned_start') and values.get('date_planned_finished'):
|
||||
computed_finished_time = workorder._calculate_date_planned_finished(start_date)
|
||||
values['date_planned_finished'] = computed_finished_time
|
||||
elif start_date and end_date:
|
||||
computed_duration = workorder._calculate_duration_expected(date_planned_start=start_date, date_planned_finished=end_date)
|
||||
values['duration_expected'] = computed_duration
|
||||
# Update MO dates if the start date of the first WO or the
|
||||
# finished date of the last WO is update.
|
||||
if workorder == workorder.production_id.workorder_ids[0] and 'date_planned_start' in values:
|
||||
if values['date_planned_start']:
|
||||
workorder.production_id.with_context(force_date=True).write({
|
||||
'date_planned_start': fields.Datetime.to_datetime(values['date_planned_start'])
|
||||
})
|
||||
if workorder == workorder.production_id.workorder_ids[-1] and 'date_planned_finished' in values:
|
||||
if values['date_planned_finished']:
|
||||
workorder.production_id.with_context(force_date=True).write({
|
||||
'date_planned_finished': fields.Datetime.to_datetime(values['date_planned_finished'])
|
||||
})
|
||||
return super(MrpWorkorder, self).write(values)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, values):
|
||||
res = super().create(values)
|
||||
# Auto-confirm manually added workorders.
|
||||
# We need to go through `_action_confirm` for all workorders of the current productions to
|
||||
# make sure the links between them are correct.
|
||||
if self.env.context.get('skip_confirm'):
|
||||
return res
|
||||
to_confirm = res.filtered(lambda wo: wo.production_id.state in ("confirmed", "progress", "to_close"))
|
||||
to_confirm = to_confirm.production_id.workorder_ids
|
||||
to_confirm._action_confirm()
|
||||
return res
|
||||
|
||||
def _action_confirm(self):
|
||||
for production in self.mapped("production_id"):
|
||||
production._link_workorders_and_moves()
|
||||
|
||||
def _get_byproduct_move_to_update(self):
|
||||
return self.production_id.move_finished_ids.filtered(lambda x: (x.product_id.id != self.production_id.product_id.id) and (x.state not in ('done', 'cancel')))
|
||||
|
||||
def _plan_workorder(self, replan=False):
|
||||
self.ensure_one()
|
||||
# Plan workorder after its predecessors
|
||||
start_date = max(self.production_id.date_planned_start, datetime.now())
|
||||
for workorder in self.blocked_by_workorder_ids:
|
||||
workorder._plan_workorder(replan)
|
||||
if workorder.date_planned_finished and workorder.date_planned_finished > start_date:
|
||||
start_date = workorder.date_planned_finished
|
||||
# Plan only suitable workorders
|
||||
if self.state not in ['pending', 'waiting', 'ready']:
|
||||
return
|
||||
if self.leave_id:
|
||||
if replan:
|
||||
self.leave_id.unlink()
|
||||
else:
|
||||
return
|
||||
# Consider workcenter and alternatives
|
||||
workcenters = self.workcenter_id | self.workcenter_id.alternative_workcenter_ids
|
||||
best_finished_date = datetime.max
|
||||
vals = {}
|
||||
for workcenter in workcenters:
|
||||
# Compute theoretical duration
|
||||
if self.workcenter_id == workcenter:
|
||||
duration_expected = self.duration_expected
|
||||
else:
|
||||
duration_expected = self._get_duration_expected(alternative_workcenter=workcenter)
|
||||
from_date, to_date = workcenter._get_first_available_slot(start_date, duration_expected)
|
||||
# If the workcenter is unavailable, try planning on the next one
|
||||
if not from_date:
|
||||
continue
|
||||
# Check if this workcenter is better than the previous ones
|
||||
if to_date and to_date < best_finished_date:
|
||||
best_start_date = from_date
|
||||
best_finished_date = to_date
|
||||
best_workcenter = workcenter
|
||||
vals = {
|
||||
'workcenter_id': workcenter.id,
|
||||
'duration_expected': duration_expected,
|
||||
}
|
||||
# If none of the workcenter are available, raise
|
||||
if best_finished_date == datetime.max:
|
||||
raise UserError(_('Impossible to plan the workorder. Please check the workcenter availabilities.'))
|
||||
# Create leave on chosen workcenter calendar
|
||||
leave = self.env['resource.calendar.leaves'].create({
|
||||
'name': self.display_name,
|
||||
'calendar_id': best_workcenter.resource_calendar_id.id,
|
||||
'date_from': best_start_date,
|
||||
'date_to': best_finished_date,
|
||||
'resource_id': best_workcenter.resource_id.id,
|
||||
'time_type': 'other'
|
||||
})
|
||||
vals['leave_id'] = leave.id
|
||||
self.write(vals)
|
||||
|
||||
def _cal_cost(self, times=None):
|
||||
self.ensure_one()
|
||||
times = times or self.time_ids
|
||||
duration = sum(times.mapped('duration'))
|
||||
return (duration / 60.0) * self.workcenter_id.costs_hour
|
||||
|
||||
@api.model
|
||||
def gantt_unavailability(self, start_date, end_date, scale, group_bys=None, rows=None):
|
||||
"""Get unavailabilities data to display in the Gantt view."""
|
||||
workcenter_ids = set()
|
||||
|
||||
def traverse_inplace(func, row, **kargs):
|
||||
res = func(row, **kargs)
|
||||
if res:
|
||||
kargs.update(res)
|
||||
for row in row.get('rows'):
|
||||
traverse_inplace(func, row, **kargs)
|
||||
|
||||
def search_workcenter_ids(row):
|
||||
if row.get('groupedBy') and row.get('groupedBy')[0] == 'workcenter_id' and row.get('resId'):
|
||||
workcenter_ids.add(row.get('resId'))
|
||||
|
||||
for row in rows:
|
||||
traverse_inplace(search_workcenter_ids, row)
|
||||
start_datetime = fields.Datetime.to_datetime(start_date)
|
||||
end_datetime = fields.Datetime.to_datetime(end_date)
|
||||
workcenters = self.env['mrp.workcenter'].browse(workcenter_ids)
|
||||
unavailability_mapping = workcenters._get_unavailability_intervals(start_datetime, end_datetime)
|
||||
|
||||
# Only notable interval (more than one case) is send to the front-end (avoid sending useless information)
|
||||
cell_dt = (scale in ['day', 'week'] and timedelta(hours=1)) or (scale == 'month' and timedelta(days=1)) or timedelta(days=28)
|
||||
|
||||
def add_unavailability(row, workcenter_id=None):
|
||||
if row.get('groupedBy') and row.get('groupedBy')[0] == 'workcenter_id' and row.get('resId'):
|
||||
workcenter_id = row.get('resId')
|
||||
if workcenter_id:
|
||||
notable_intervals = filter(lambda interval: interval[1] - interval[0] >= cell_dt, unavailability_mapping[workcenter_id])
|
||||
row['unavailabilities'] = [{'start': interval[0], 'stop': interval[1]} for interval in notable_intervals]
|
||||
return {'workcenter_id': workcenter_id}
|
||||
|
||||
for row in rows:
|
||||
traverse_inplace(add_unavailability, row)
|
||||
return rows
|
||||
|
||||
def button_start(self):
|
||||
self.ensure_one()
|
||||
if any(not time.date_end for time in self.time_ids.filtered(lambda t: t.user_id.id == self.env.user.id)):
|
||||
return True
|
||||
# As button_start is automatically called in the new view
|
||||
if self.state in ('done', 'cancel'):
|
||||
return True
|
||||
|
||||
if self.production_id.state != 'progress':
|
||||
self.production_id.write({
|
||||
'date_start': datetime.now(),
|
||||
})
|
||||
|
||||
if self.product_tracking == 'serial' and self.qty_producing == 0:
|
||||
self.qty_producing = 1.0
|
||||
elif self.qty_producing == 0:
|
||||
self.qty_producing = self.qty_remaining
|
||||
|
||||
if self._should_start_timer():
|
||||
self.env['mrp.workcenter.productivity'].create(
|
||||
self._prepare_timeline_vals(self.duration, datetime.now())
|
||||
)
|
||||
|
||||
if self.state == 'progress':
|
||||
return True
|
||||
start_date = datetime.now()
|
||||
vals = {
|
||||
'state': 'progress',
|
||||
'date_start': start_date,
|
||||
}
|
||||
if not self.leave_id:
|
||||
leave = self.env['resource.calendar.leaves'].create({
|
||||
'name': self.display_name,
|
||||
'calendar_id': self.workcenter_id.resource_calendar_id.id,
|
||||
'date_from': start_date,
|
||||
'date_to': start_date + relativedelta(minutes=self.duration_expected),
|
||||
'resource_id': self.workcenter_id.resource_id.id,
|
||||
'time_type': 'other'
|
||||
})
|
||||
vals['leave_id'] = leave.id
|
||||
return self.write(vals)
|
||||
else:
|
||||
if not self.date_planned_start or self.date_planned_start > start_date:
|
||||
vals['date_planned_start'] = start_date
|
||||
vals['date_planned_finished'] = self._calculate_date_planned_finished(start_date)
|
||||
if self.date_planned_finished and self.date_planned_finished < start_date:
|
||||
vals['date_planned_finished'] = start_date
|
||||
return self.with_context(bypass_duration_calculation=True).write(vals)
|
||||
|
||||
def button_finish(self):
|
||||
end_date = fields.Datetime.now()
|
||||
for workorder in self:
|
||||
if workorder.state in ('done', 'cancel'):
|
||||
continue
|
||||
workorder.end_all()
|
||||
vals = {
|
||||
'qty_produced': workorder.qty_produced or workorder.qty_producing or workorder.qty_production,
|
||||
'state': 'done',
|
||||
'date_finished': end_date,
|
||||
'date_planned_finished': end_date,
|
||||
'costs_hour': workorder.workcenter_id.costs_hour
|
||||
}
|
||||
if not workorder.date_start:
|
||||
vals['date_start'] = end_date
|
||||
if not workorder.date_planned_start or end_date < workorder.date_planned_start:
|
||||
vals['date_planned_start'] = end_date
|
||||
workorder.with_context(bypass_duration_calculation=True).write(vals)
|
||||
return True
|
||||
|
||||
def _domain_mrp_workcenter_productivity(self, doall):
|
||||
domain = [('workorder_id', 'in', self.ids), ('date_end', '=', False)]
|
||||
if not doall:
|
||||
domain = expression.AND([domain, [('user_id', '=', self.env.user.id)]])
|
||||
return domain
|
||||
|
||||
def end_previous(self, doall=False):
|
||||
"""
|
||||
@param: doall: This will close all open time lines on the open work orders when doall = True, otherwise
|
||||
only the one of the current user
|
||||
"""
|
||||
# TDE CLEANME
|
||||
self.env['mrp.workcenter.productivity'].search(
|
||||
self._domain_mrp_workcenter_productivity(doall),
|
||||
limit=None if doall else 1
|
||||
)._close()
|
||||
return True
|
||||
|
||||
def end_all(self):
|
||||
return self.end_previous(doall=True)
|
||||
|
||||
def button_pending(self):
|
||||
self.end_previous()
|
||||
return True
|
||||
|
||||
def button_unblock(self):
|
||||
for order in self:
|
||||
order.workcenter_id.unblock()
|
||||
return True
|
||||
|
||||
def action_cancel(self):
|
||||
self.leave_id.unlink()
|
||||
self.end_all()
|
||||
return self.write({'state': 'cancel'})
|
||||
|
||||
def action_replan(self):
|
||||
"""Replan a work order.
|
||||
|
||||
It actually replans every "ready" or "pending"
|
||||
work orders of the linked manufacturing orders.
|
||||
"""
|
||||
for production in self.production_id:
|
||||
production._plan_workorders(replan=True)
|
||||
return True
|
||||
|
||||
def button_done(self):
|
||||
if any(x.state in ('done', 'cancel') for x in self):
|
||||
raise UserError(_('A Manufacturing Order is already done or cancelled.'))
|
||||
self.end_all()
|
||||
end_date = datetime.now()
|
||||
return self.write({
|
||||
'state': 'done',
|
||||
'date_finished': end_date,
|
||||
'date_planned_finished': end_date,
|
||||
'costs_hour': self.workcenter_id.costs_hour
|
||||
})
|
||||
|
||||
def button_scrap(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'name': _('Scrap'),
|
||||
'view_mode': 'form',
|
||||
'res_model': 'stock.scrap',
|
||||
'views': [(self.env.ref('stock.stock_scrap_form_view2').id, 'form')],
|
||||
'type': 'ir.actions.act_window',
|
||||
'context': {'default_company_id': self.production_id.company_id.id,
|
||||
'default_workorder_id': self.id,
|
||||
'default_production_id': self.production_id.id,
|
||||
'product_ids': (self.production_id.move_raw_ids.filtered(lambda x: x.state not in ('done', 'cancel')) | self.production_id.move_finished_ids.filtered(lambda x: x.state == 'done')).mapped('product_id').ids},
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_see_move_scrap(self):
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("stock.action_stock_scrap")
|
||||
action['domain'] = [('workorder_id', '=', self.id)]
|
||||
return action
|
||||
|
||||
def action_open_wizard(self):
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("mrp.mrp_workorder_mrp_production_form")
|
||||
action['res_id'] = self.id
|
||||
return action
|
||||
|
||||
@api.depends('qty_production', 'qty_reported_from_previous_wo', 'qty_produced', 'production_id.product_uom_id')
|
||||
def _compute_qty_remaining(self):
|
||||
for wo in self:
|
||||
if wo.production_id.product_uom_id:
|
||||
wo.qty_remaining = max(float_round(wo.qty_production - wo.qty_reported_from_previous_wo - wo.qty_produced, precision_rounding=wo.production_id.product_uom_id.rounding), 0)
|
||||
else:
|
||||
wo.qty_remaining = 0
|
||||
|
||||
def _get_duration_expected(self, alternative_workcenter=False, ratio=1):
|
||||
self.ensure_one()
|
||||
if not self.workcenter_id:
|
||||
return self.duration_expected
|
||||
if not self.operation_id:
|
||||
duration_expected_working = (self.duration_expected - self.workcenter_id.time_start - self.workcenter_id.time_stop) * self.workcenter_id.time_efficiency / 100.0
|
||||
if duration_expected_working < 0:
|
||||
duration_expected_working = 0
|
||||
if self.qty_producing not in (0, self.qty_production, self._origin.qty_producing):
|
||||
qty_ratio = self.qty_producing / (self._origin.qty_producing or self.qty_production)
|
||||
else:
|
||||
qty_ratio = 1
|
||||
return self.workcenter_id._get_expected_duration(self.product_id) + duration_expected_working * qty_ratio * ratio * 100.0 / self.workcenter_id.time_efficiency
|
||||
qty_production = self.production_id.product_uom_id._compute_quantity(self.qty_producing or self.qty_production, self.production_id.product_id.uom_id)
|
||||
capacity = self.workcenter_id._get_capacity(self.product_id)
|
||||
cycle_number = float_round(qty_production / capacity, precision_digits=0, rounding_method='UP')
|
||||
if alternative_workcenter:
|
||||
# TODO : find a better alternative : the settings of workcenter can change
|
||||
duration_expected_working = (self.duration_expected - self.workcenter_id._get_expected_duration(self.product_id)) * self.workcenter_id.time_efficiency / (100.0 * cycle_number)
|
||||
if duration_expected_working < 0:
|
||||
duration_expected_working = 0
|
||||
capacity = alternative_workcenter._get_capacity(self.product_id)
|
||||
alternative_wc_cycle_nb = float_round(qty_production / capacity, precision_digits=0, rounding_method='UP')
|
||||
return alternative_workcenter._get_expected_duration(self.product_id) + alternative_wc_cycle_nb * duration_expected_working * 100.0 / alternative_workcenter.time_efficiency
|
||||
time_cycle = self.operation_id.time_cycle
|
||||
return self.workcenter_id._get_expected_duration(self.product_id) + cycle_number * time_cycle * 100.0 / self.workcenter_id.time_efficiency
|
||||
|
||||
def _get_conflicted_workorder_ids(self):
|
||||
"""Get conlicted workorder(s) with self.
|
||||
|
||||
Conflict means having two workorders in the same time in the same workcenter.
|
||||
|
||||
:return: defaultdict with key as workorder id of self and value as related conflicted workorder
|
||||
"""
|
||||
self.flush_model(['state', 'date_planned_start', 'date_planned_finished', 'workcenter_id'])
|
||||
sql = """
|
||||
SELECT wo1.id, wo2.id
|
||||
FROM mrp_workorder wo1, mrp_workorder wo2
|
||||
WHERE
|
||||
wo1.id IN %s
|
||||
AND wo1.state IN ('pending', 'waiting', 'ready')
|
||||
AND wo2.state IN ('pending', 'waiting', 'ready')
|
||||
AND wo1.id != wo2.id
|
||||
AND wo1.workcenter_id = wo2.workcenter_id
|
||||
AND (DATE_TRUNC('second', wo2.date_planned_start), DATE_TRUNC('second', wo2.date_planned_finished))
|
||||
OVERLAPS (DATE_TRUNC('second', wo1.date_planned_start), DATE_TRUNC('second', wo1.date_planned_finished))
|
||||
"""
|
||||
self.env.cr.execute(sql, [tuple(self.ids)])
|
||||
res = defaultdict(list)
|
||||
for wo1, wo2 in self.env.cr.fetchall():
|
||||
res[wo1].append(wo2)
|
||||
return res
|
||||
|
||||
def _prepare_timeline_vals(self, duration, date_start, date_end=False):
|
||||
# Need a loss in case of the real time exceeding the expected
|
||||
if not self.duration_expected or duration <= self.duration_expected:
|
||||
loss_id = self.env['mrp.workcenter.productivity.loss'].search([('loss_type', '=', 'productive')], limit=1)
|
||||
if not len(loss_id):
|
||||
raise UserError(_("You need to define at least one productivity loss in the category 'Productivity'. Create one from the Manufacturing app, menu: Configuration / Productivity Losses."))
|
||||
else:
|
||||
loss_id = self.env['mrp.workcenter.productivity.loss'].search([('loss_type', '=', 'performance')], limit=1)
|
||||
if not len(loss_id):
|
||||
raise UserError(_("You need to define at least one productivity loss in the category 'Performance'. Create one from the Manufacturing app, menu: Configuration / Productivity Losses."))
|
||||
return {
|
||||
'workorder_id': self.id,
|
||||
'workcenter_id': self.workcenter_id.id,
|
||||
'description': _('Time Tracking: %(user)s', user=self.env.user.name),
|
||||
'loss_id': loss_id[0].id,
|
||||
'date_start': date_start.replace(microsecond=0),
|
||||
'date_end': date_end.replace(microsecond=0) if date_end else date_end,
|
||||
'user_id': self.env.user.id, # FIXME sle: can be inconsistent with company_id
|
||||
'company_id': self.company_id.id,
|
||||
}
|
||||
|
||||
def _update_finished_move(self):
|
||||
""" Update the finished move & move lines in order to set the finished
|
||||
product lot on it as well as the produced quantity. This method get the
|
||||
information either from the last workorder or from the Produce wizard."""
|
||||
production_move = self.production_id.move_finished_ids.filtered(
|
||||
lambda move: move.product_id == self.product_id and
|
||||
move.state not in ('done', 'cancel')
|
||||
)
|
||||
if not production_move:
|
||||
return
|
||||
if production_move.product_id.tracking != 'none':
|
||||
if not self.finished_lot_id:
|
||||
raise UserError(_('You need to provide a lot for the finished product.'))
|
||||
move_line = production_move.move_line_ids.filtered(
|
||||
lambda line: line.lot_id.id == self.finished_lot_id.id
|
||||
)
|
||||
if move_line:
|
||||
if self.product_id.tracking == 'serial':
|
||||
raise UserError(_('You cannot produce the same serial number twice.'))
|
||||
move_line.reserved_uom_qty += self.qty_producing
|
||||
move_line.qty_done += self.qty_producing
|
||||
else:
|
||||
quantity = self.product_uom_id._compute_quantity(self.qty_producing, self.product_id.uom_id, rounding_method='HALF-UP')
|
||||
putaway_location = production_move.location_dest_id._get_putaway_strategy(self.product_id, quantity)
|
||||
move_line.create({
|
||||
'move_id': production_move.id,
|
||||
'product_id': production_move.product_id.id,
|
||||
'lot_id': self.finished_lot_id.id,
|
||||
'reserved_uom_qty': self.qty_producing,
|
||||
'product_uom_id': self.product_uom_id.id,
|
||||
'qty_done': self.qty_producing,
|
||||
'location_id': production_move.location_id.id,
|
||||
'location_dest_id': putaway_location.id,
|
||||
})
|
||||
else:
|
||||
rounding = production_move.product_uom.rounding
|
||||
production_move._set_quantity_done(
|
||||
float_round(self.qty_producing, precision_rounding=rounding)
|
||||
)
|
||||
|
||||
def _check_sn_uniqueness(self):
|
||||
# todo master: remove
|
||||
pass
|
||||
|
||||
def _should_start_timer(self):
|
||||
return True
|
||||
|
||||
def _update_qty_producing(self, quantity):
|
||||
self.ensure_one()
|
||||
if self.qty_producing:
|
||||
self.qty_producing = quantity
|
||||
|
||||
def get_working_duration(self):
|
||||
"""Get the additional duration for 'open times' i.e. productivity lines with no date_end."""
|
||||
self.ensure_one()
|
||||
duration = 0
|
||||
for time in self.time_ids.filtered(lambda time: not time.date_end):
|
||||
duration += (datetime.now() - time.date_start).total_seconds() / 60
|
||||
return duration
|
||||
420
odoo-bringout-oca-ocb-mrp/mrp/models/product.py
Normal file
420
odoo-bringout-oca-ocb-mrp/mrp/models/product.py
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import collections
|
||||
from datetime import timedelta
|
||||
from itertools import groupby
|
||||
import operator as py_operator
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.tools import groupby
|
||||
from odoo.tools.float_utils import float_round, float_is_zero
|
||||
|
||||
|
||||
OPERATORS = {
|
||||
'<': py_operator.lt,
|
||||
'>': py_operator.gt,
|
||||
'<=': py_operator.le,
|
||||
'>=': py_operator.ge,
|
||||
'=': py_operator.eq,
|
||||
'!=': py_operator.ne
|
||||
}
|
||||
|
||||
class ProductTemplate(models.Model):
|
||||
_inherit = "product.template"
|
||||
|
||||
bom_line_ids = fields.One2many('mrp.bom.line', 'product_tmpl_id', 'BoM Components')
|
||||
bom_ids = fields.One2many('mrp.bom', 'product_tmpl_id', 'Bill of Materials')
|
||||
bom_count = fields.Integer('# Bill of Material',
|
||||
compute='_compute_bom_count', compute_sudo=False)
|
||||
used_in_bom_count = fields.Integer('# of BoM Where is Used',
|
||||
compute='_compute_used_in_bom_count', compute_sudo=False)
|
||||
mrp_product_qty = fields.Float('Manufactured', digits='Product Unit of Measure',
|
||||
compute='_compute_mrp_product_qty', compute_sudo=False)
|
||||
produce_delay = fields.Float(
|
||||
'Manufacturing Lead Time', default=0.0,
|
||||
help="Average lead time in days to manufacture this product. In the case of multi-level BOM, the manufacturing lead times of the components will be added. In case the product is subcontracted, this can be used to determine the date at which components should be sent to the subcontractor.")
|
||||
is_kits = fields.Boolean(compute='_compute_is_kits', search='_search_is_kits')
|
||||
days_to_prepare_mo = fields.Float(
|
||||
string="Days to prepare Manufacturing Order", default=0.0,
|
||||
help="Create and confirm Manufacturing Orders this many days in advance, to have enough time to replenish components or manufacture semi-finished products.\n"
|
||||
"Note that security lead times will also be considered when appropriate.")
|
||||
|
||||
def _compute_bom_count(self):
|
||||
for product in self:
|
||||
product.bom_count = self.env['mrp.bom'].search_count(['|', ('product_tmpl_id', '=', product.id), ('byproduct_ids.product_id.product_tmpl_id', '=', product.id)])
|
||||
|
||||
@api.depends_context('company')
|
||||
def _compute_is_kits(self):
|
||||
domain = [('company_id', 'in', [False, self.env.company.id]),
|
||||
('product_tmpl_id', 'in', self.ids),
|
||||
('active', '=', True),
|
||||
('type', '=', 'phantom')]
|
||||
bom_mapping = self.env['mrp.bom'].sudo()._read_group(
|
||||
domain, ['product_tmpl_id'], ['product_tmpl_id'], orderby='id')
|
||||
kits_ids = {b['product_tmpl_id'][0] for b in bom_mapping}
|
||||
for template in self:
|
||||
template.is_kits = (template.id in kits_ids)
|
||||
|
||||
def _search_is_kits(self, operator, value):
|
||||
assert operator in ('=', '!='), 'Unsupported operator'
|
||||
bom_tmpl_query = self.env['mrp.bom'].sudo()._search(
|
||||
[('company_id', 'in', [False] + self.env.companies.ids),
|
||||
('type', '=', 'phantom'), ('active', '=', True)])
|
||||
neg = ''
|
||||
if (operator == '=' and not value) or (operator == '!=' and value):
|
||||
neg = 'not '
|
||||
return [('id', neg + 'inselect', bom_tmpl_query.subselect('product_tmpl_id'))]
|
||||
|
||||
def _compute_show_qty_status_button(self):
|
||||
super()._compute_show_qty_status_button()
|
||||
for template in self:
|
||||
if template.is_kits:
|
||||
template.show_on_hand_qty_status_button = template.product_variant_count <= 1
|
||||
template.show_forecasted_qty_status_button = False
|
||||
|
||||
def _compute_used_in_bom_count(self):
|
||||
for template in self:
|
||||
template.used_in_bom_count = self.env['mrp.bom'].search_count(
|
||||
[('bom_line_ids.product_tmpl_id', '=', template.id)])
|
||||
|
||||
def write(self, values):
|
||||
if 'active' in values:
|
||||
self.filtered(lambda p: p.active != values['active']).with_context(active_test=False).bom_ids.write({
|
||||
'active': values['active']
|
||||
})
|
||||
return super().write(values)
|
||||
|
||||
def action_used_in_bom(self):
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("mrp.mrp_bom_form_action")
|
||||
action['domain'] = [('bom_line_ids.product_tmpl_id', '=', self.id)]
|
||||
return action
|
||||
|
||||
def _compute_mrp_product_qty(self):
|
||||
for template in self:
|
||||
template.mrp_product_qty = float_round(sum(template.mapped('product_variant_ids').mapped('mrp_product_qty')), precision_rounding=template.uom_id.rounding)
|
||||
|
||||
def action_view_mos(self):
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("mrp.mrp_production_report")
|
||||
action['domain'] = [('state', '=', 'done'), ('product_tmpl_id', 'in', self.ids)]
|
||||
action['context'] = {
|
||||
'graph_measure': 'product_uom_qty',
|
||||
'search_default_filter_plan_date': 1,
|
||||
}
|
||||
return action
|
||||
|
||||
def action_compute_bom_days(self):
|
||||
templates = self.filtered(lambda t: t.bom_count > 0)
|
||||
if templates:
|
||||
return templates.mapped('product_variant_id').action_compute_bom_days()
|
||||
|
||||
def action_archive(self):
|
||||
filtered_products = self.env['mrp.bom.line'].search([('product_id', 'in', self.product_variant_ids.ids), ('bom_id.active', '=', True)]).product_id.mapped('display_name')
|
||||
res = super().action_archive()
|
||||
if filtered_products:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _("Note that product(s): '%s' is/are still linked to active Bill of Materials, "
|
||||
"which means that the product can still be used on it/them.", filtered_products),
|
||||
'type': 'warning',
|
||||
'sticky': True, #True/False will display for few seconds if false
|
||||
'next': {'type': 'ir.actions.act_window_close'},
|
||||
},
|
||||
}
|
||||
return res
|
||||
|
||||
class ProductProduct(models.Model):
|
||||
_inherit = "product.product"
|
||||
|
||||
variant_bom_ids = fields.One2many('mrp.bom', 'product_id', 'BOM Product Variants')
|
||||
bom_line_ids = fields.One2many('mrp.bom.line', 'product_id', 'BoM Components')
|
||||
bom_count = fields.Integer('# Bill of Material',
|
||||
compute='_compute_bom_count', compute_sudo=False)
|
||||
used_in_bom_count = fields.Integer('# BoM Where Used',
|
||||
compute='_compute_used_in_bom_count', compute_sudo=False)
|
||||
mrp_product_qty = fields.Float('Manufactured', digits='Product Unit of Measure',
|
||||
compute='_compute_mrp_product_qty', compute_sudo=False)
|
||||
is_kits = fields.Boolean(compute="_compute_is_kits", search='_search_is_kits')
|
||||
|
||||
def _compute_bom_count(self):
|
||||
for product in self:
|
||||
product.bom_count = self.env['mrp.bom'].search_count(['|', '|', ('byproduct_ids.product_id', '=', product.id), ('product_id', '=', product.id), '&', ('product_id', '=', False), ('product_tmpl_id', '=', product.product_tmpl_id.id)])
|
||||
|
||||
@api.depends_context('company')
|
||||
def _compute_is_kits(self):
|
||||
domain = [
|
||||
'&', ('company_id', 'in', [False, self.env.company.id]),
|
||||
'&', ('active', '=', True),
|
||||
'&', ('type', '=', 'phantom'),
|
||||
'|', ('product_id', 'in', self.ids),
|
||||
'&', ('product_id', '=', False),
|
||||
('product_tmpl_id', 'in', self.product_tmpl_id.ids)]
|
||||
tmpl_bom_mapping = self.env['mrp.bom'].sudo()._read_group(
|
||||
domain, ['product_tmpl_id'], ['product_tmpl_id'], orderby='id')
|
||||
product_bom_mapping = self.env['mrp.bom'].sudo()._read_group(
|
||||
domain, ['product_id'], ['product_id'], orderby='id')
|
||||
kits_template_ids = {b['product_tmpl_id'][0] for b in tmpl_bom_mapping}
|
||||
kits_product_ids = {b['product_id'][0] for b in product_bom_mapping if b['product_id']}
|
||||
for product in self:
|
||||
product.is_kits = (product.id in kits_product_ids or product.product_tmpl_id.id in kits_template_ids)
|
||||
|
||||
def _search_is_kits(self, operator, value):
|
||||
assert operator in ('=', '!='), 'Unsupported operator'
|
||||
bom_tmpl_query = self.env['mrp.bom'].sudo()._search(
|
||||
[('company_id', 'in', [False] + self.env.companies.ids),
|
||||
('active', '=', True),
|
||||
('type', '=', 'phantom'), ('product_id', '=', False)])
|
||||
bom_product_query = self.env['mrp.bom'].sudo()._search(
|
||||
[('company_id', 'in', [False] + self.env.companies.ids),
|
||||
('type', '=', 'phantom'), ('product_id', '!=', False)])
|
||||
neg = ''
|
||||
op = '|'
|
||||
if (operator == '=' and not value) or (operator == '!=' and value):
|
||||
neg = 'not '
|
||||
op = '&'
|
||||
return [op, ('product_tmpl_id', neg + 'inselect', bom_tmpl_query.subselect('product_tmpl_id')),
|
||||
('id', neg + 'inselect', bom_product_query.subselect('product_id'))]
|
||||
|
||||
def _compute_show_qty_status_button(self):
|
||||
super()._compute_show_qty_status_button()
|
||||
for product in self:
|
||||
if product.is_kits:
|
||||
product.show_on_hand_qty_status_button = True
|
||||
product.show_forecasted_qty_status_button = False
|
||||
|
||||
def _compute_used_in_bom_count(self):
|
||||
for product in self:
|
||||
product.used_in_bom_count = self.env['mrp.bom'].search_count([('bom_line_ids.product_id', '=', product.id)])
|
||||
|
||||
def write(self, values):
|
||||
if 'active' in values:
|
||||
self.filtered(lambda p: p.active != values['active']).with_context(active_test=False).variant_bom_ids.write({
|
||||
'active': values['active']
|
||||
})
|
||||
return super().write(values)
|
||||
|
||||
def get_components(self):
|
||||
""" Return the components list ids in case of kit product.
|
||||
Return the product itself otherwise"""
|
||||
self.ensure_one()
|
||||
bom_kit = self.env['mrp.bom']._bom_find(self, bom_type='phantom')[self]
|
||||
if bom_kit:
|
||||
boms, bom_sub_lines = bom_kit.explode(self, 1)
|
||||
return [bom_line.product_id.id for bom_line, data in bom_sub_lines if bom_line.product_id.type == 'product']
|
||||
else:
|
||||
return super(ProductProduct, self).get_components()
|
||||
|
||||
def action_used_in_bom(self):
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("mrp.mrp_bom_form_action")
|
||||
action['domain'] = [('bom_line_ids.product_id', '=', self.id)]
|
||||
return action
|
||||
|
||||
def _compute_mrp_product_qty(self):
|
||||
date_from = fields.Datetime.to_string(fields.datetime.now() - timedelta(days=365))
|
||||
#TODO: state = done?
|
||||
domain = [('state', '=', 'done'), ('product_id', 'in', self.ids), ('date_planned_start', '>', date_from)]
|
||||
read_group_res = self.env['mrp.production']._read_group(domain, ['product_id', 'product_uom_qty'], ['product_id'])
|
||||
mapped_data = dict([(data['product_id'][0], data['product_uom_qty']) for data in read_group_res])
|
||||
for product in self:
|
||||
if not product.id:
|
||||
product.mrp_product_qty = 0.0
|
||||
continue
|
||||
product.mrp_product_qty = float_round(mapped_data.get(product.id, 0), precision_rounding=product.uom_id.rounding)
|
||||
|
||||
def _compute_quantities_dict(self, lot_id, owner_id, package_id, from_date=False, to_date=False):
|
||||
""" When the product is a kit, this override computes the fields :
|
||||
- 'virtual_available'
|
||||
- 'qty_available'
|
||||
- 'incoming_qty'
|
||||
- 'outgoing_qty'
|
||||
- 'free_qty'
|
||||
|
||||
This override is used to get the correct quantities of products
|
||||
with 'phantom' as BoM type.
|
||||
"""
|
||||
bom_kits = self.env['mrp.bom']._bom_find(self, bom_type='phantom')
|
||||
kits = self.filtered(lambda p: bom_kits.get(p))
|
||||
regular_products = self - kits
|
||||
res = (
|
||||
super(ProductProduct, regular_products)._compute_quantities_dict(lot_id, owner_id, package_id, from_date=from_date, to_date=to_date)
|
||||
if regular_products
|
||||
else {}
|
||||
)
|
||||
qties = self.env.context.get("mrp_compute_quantities", {})
|
||||
qties.update(res)
|
||||
# pre-compute bom lines and identify missing kit components to prefetch
|
||||
bom_sub_lines_per_kit = {}
|
||||
prefetch_component_ids = set()
|
||||
for product in bom_kits:
|
||||
__, bom_sub_lines = bom_kits[product].explode(product, 1)
|
||||
bom_sub_lines_per_kit[product] = bom_sub_lines
|
||||
for bom_line, __ in bom_sub_lines:
|
||||
if bom_line.product_id.id not in qties:
|
||||
prefetch_component_ids.add(bom_line.product_id.id)
|
||||
# compute kit quantities
|
||||
for product in bom_kits:
|
||||
bom_sub_lines = bom_sub_lines_per_kit[product]
|
||||
# group lines by component
|
||||
bom_sub_lines_grouped = collections.defaultdict(list)
|
||||
for info in bom_sub_lines:
|
||||
bom_sub_lines_grouped[info[0].product_id].append(info)
|
||||
ratios_virtual_available = []
|
||||
ratios_qty_available = []
|
||||
ratios_incoming_qty = []
|
||||
ratios_outgoing_qty = []
|
||||
ratios_free_qty = []
|
||||
|
||||
for component, bom_sub_lines in bom_sub_lines_grouped.items():
|
||||
component = component.with_context(mrp_compute_quantities=qties).with_prefetch(prefetch_component_ids)
|
||||
qty_per_kit = 0
|
||||
for bom_line, bom_line_data in bom_sub_lines:
|
||||
if component.type != 'product' or float_is_zero(bom_line_data['qty'], precision_rounding=bom_line.product_uom_id.rounding):
|
||||
# As BoMs allow components with 0 qty, a.k.a. optionnal components, we simply skip those
|
||||
# to avoid a division by zero. The same logic is applied to non-storable products as those
|
||||
# products have 0 qty available.
|
||||
continue
|
||||
uom_qty_per_kit = bom_line_data['qty'] / bom_line_data['original_qty']
|
||||
qty_per_kit += bom_line.product_uom_id._compute_quantity(uom_qty_per_kit, bom_line.product_id.uom_id, round=False, raise_if_failure=False)
|
||||
if not qty_per_kit:
|
||||
continue
|
||||
rounding = component.uom_id.rounding
|
||||
component_res = (
|
||||
qties.get(component.id)
|
||||
if component.id in qties
|
||||
else {
|
||||
"virtual_available": float_round(component.virtual_available, precision_rounding=rounding),
|
||||
"qty_available": float_round(component.qty_available, precision_rounding=rounding),
|
||||
"incoming_qty": float_round(component.incoming_qty, precision_rounding=rounding),
|
||||
"outgoing_qty": float_round(component.outgoing_qty, precision_rounding=rounding),
|
||||
"free_qty": float_round(component.free_qty, precision_rounding=rounding),
|
||||
}
|
||||
)
|
||||
ratios_virtual_available.append(float_round(component_res["virtual_available"] / qty_per_kit, precision_rounding=rounding, rounding_method='DOWN'))
|
||||
ratios_qty_available.append(float_round(component_res["qty_available"] / qty_per_kit, precision_rounding=rounding, rounding_method='DOWN'))
|
||||
ratios_incoming_qty.append(float_round(component_res["incoming_qty"] / qty_per_kit, precision_rounding=rounding, rounding_method='DOWN'))
|
||||
ratios_outgoing_qty.append(float_round(component_res["outgoing_qty"] / qty_per_kit, precision_rounding=rounding, rounding_method='DOWN'))
|
||||
ratios_free_qty.append(float_round(component_res["free_qty"] / qty_per_kit, precision_rounding=rounding, rounding_method='DOWN'))
|
||||
if bom_sub_lines and ratios_virtual_available: # Guard against all cnsumable bom: at least one ratio should be present.
|
||||
res[product.id] = {
|
||||
'virtual_available': float_round(min(ratios_virtual_available) * bom_kits[product].product_qty, precision_rounding=rounding) // 1,
|
||||
'qty_available': float_round(min(ratios_qty_available) * bom_kits[product].product_qty, precision_rounding=rounding) // 1,
|
||||
'incoming_qty': float_round(min(ratios_incoming_qty) * bom_kits[product].product_qty, precision_rounding=rounding) // 1,
|
||||
'outgoing_qty': float_round(min(ratios_outgoing_qty) * bom_kits[product].product_qty, precision_rounding=rounding) // 1,
|
||||
'free_qty': float_round(min(ratios_free_qty) * bom_kits[product].product_qty, precision_rounding=rounding) // 1,
|
||||
}
|
||||
else:
|
||||
res[product.id] = {
|
||||
'virtual_available': 0,
|
||||
'qty_available': 0,
|
||||
'incoming_qty': 0,
|
||||
'outgoing_qty': 0,
|
||||
'free_qty': 0,
|
||||
}
|
||||
|
||||
return res
|
||||
|
||||
def action_view_bom(self):
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("mrp.product_open_bom")
|
||||
template_ids = self.mapped('product_tmpl_id').ids
|
||||
# bom specific to this variant or global to template or that contains the product as a byproduct
|
||||
action['context'] = {
|
||||
'default_product_tmpl_id': template_ids[0],
|
||||
'default_product_id': self.env.user.has_group('product.group_product_variant') and self.ids[0] or False,
|
||||
}
|
||||
action['domain'] = ['|', '|', ('byproduct_ids.product_id', 'in', self.ids), ('product_id', 'in', self.ids), '&', ('product_id', '=', False), ('product_tmpl_id', 'in', template_ids)]
|
||||
return action
|
||||
|
||||
def action_view_mos(self):
|
||||
action = self.product_tmpl_id.action_view_mos()
|
||||
action['domain'] = [('state', '=', 'done'), ('product_id', 'in', self.ids)]
|
||||
return action
|
||||
|
||||
def action_open_quants(self):
|
||||
bom_kits = self.env['mrp.bom']._bom_find(self, bom_type='phantom')
|
||||
components = self - self.env['product.product'].concat(*list(bom_kits.keys()))
|
||||
for product in bom_kits:
|
||||
boms, bom_sub_lines = bom_kits[product].explode(product, 1)
|
||||
components |= self.env['product.product'].concat(*[l[0].product_id for l in bom_sub_lines])
|
||||
res = super(ProductProduct, components).action_open_quants()
|
||||
if bom_kits:
|
||||
res['context']['single_product'] = False
|
||||
res['context'].pop('default_product_tmpl_id', None)
|
||||
return res
|
||||
|
||||
def action_compute_bom_days(self):
|
||||
bom_by_products = self.env['mrp.bom']._bom_find(self)
|
||||
company_id = self.env.context.get('default_company_id', self.env.company.id)
|
||||
warehouse = self.env['stock.warehouse'].search([('company_id', '=', company_id)], limit=1)
|
||||
for product in self:
|
||||
bom_data = self.env['report.mrp.report_bom_structure'].with_context(minimized=True)._get_bom_data(bom_by_products[product], warehouse, product, ignore_stock=True)
|
||||
if bom_data.get('availability_state') == 'unavailable' and not bom_data.get('components_available', True):
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Cannot compute days to prepare due to missing route info for at least 1 component or for the final product.'),
|
||||
'sticky': False,
|
||||
}
|
||||
}
|
||||
availability_delay = bom_data.get('resupply_avail_delay')
|
||||
product.days_to_prepare_mo = availability_delay - bom_data.get('lead_time', 0) if availability_delay else 0
|
||||
|
||||
def _match_all_variant_values(self, product_template_attribute_value_ids):
|
||||
""" It currently checks that all variant values (`product_template_attribute_value_ids`)
|
||||
are in the product (`self`).
|
||||
|
||||
If multiple values are encoded for the same attribute line, only one of
|
||||
them has to be found on the variant.
|
||||
"""
|
||||
self.ensure_one()
|
||||
# The intersection of the values of the product and those of the line satisfy:
|
||||
# * the number of items equals the number of attributes (since a product cannot
|
||||
# have multiple values for the same attribute),
|
||||
# * the attributes are a subset of the attributes of the line.
|
||||
return len(self.product_template_attribute_value_ids & product_template_attribute_value_ids) == len(product_template_attribute_value_ids.attribute_id)
|
||||
|
||||
def _count_returned_sn_products(self, sn_lot):
|
||||
res = self.env['stock.move.line'].search_count([
|
||||
('lot_id', '=', sn_lot.id),
|
||||
('qty_done', '=', 1),
|
||||
('state', '=', 'done'),
|
||||
('production_id', '=', False),
|
||||
('location_id.usage', '=', 'production'),
|
||||
('move_id.unbuild_id', '!=', False),
|
||||
])
|
||||
return super()._count_returned_sn_products(sn_lot) + res
|
||||
|
||||
def _search_qty_available_new(self, operator, value, lot_id=False, owner_id=False, package_id=False):
|
||||
'''extending the method in stock.product to take into account kits'''
|
||||
product_ids = super(ProductProduct, self)._search_qty_available_new(operator, value, lot_id, owner_id, package_id)
|
||||
kit_boms = self.env['mrp.bom'].search([('type', "=", 'phantom')])
|
||||
kit_products = self.env['product.product']
|
||||
for kit in kit_boms:
|
||||
if kit.product_id:
|
||||
kit_products |= kit.product_id
|
||||
else:
|
||||
kit_products |= kit.product_tmpl_id.product_variant_ids
|
||||
for product in kit_products:
|
||||
if OPERATORS[operator](product.qty_available, value):
|
||||
product_ids.append(product.id)
|
||||
return list(set(product_ids))
|
||||
|
||||
def action_archive(self):
|
||||
filtered_products = self.env['mrp.bom.line'].search([('product_id', 'in', self.ids), ('bom_id.active', '=', True)]).product_id.mapped('display_name')
|
||||
res = super().action_archive()
|
||||
if filtered_products:
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _("Note that product(s): '%s' is/are still linked to active Bill of Materials, "
|
||||
"which means that the product can still be used on it/them.", filtered_products),
|
||||
'type': 'warning',
|
||||
'sticky': True, #True/False will display for few seconds if false
|
||||
'next': {'type': 'ir.actions.act_window_close'},
|
||||
},
|
||||
}
|
||||
return res
|
||||
43
odoo-bringout-oca-ocb-mrp/mrp/models/res_company.py
Normal file
43
odoo-bringout-oca-ocb-mrp/mrp/models/res_company.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class Company(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
manufacturing_lead = fields.Float(
|
||||
'Manufacturing Lead Time', default=0.0, required=True,
|
||||
help="Security days for each manufacturing operation.")
|
||||
|
||||
def _create_unbuild_sequence(self):
|
||||
unbuild_vals = []
|
||||
for company in self:
|
||||
unbuild_vals.append({
|
||||
'name': 'Unbuild',
|
||||
'code': 'mrp.unbuild',
|
||||
'company_id': company.id,
|
||||
'prefix': 'UB/',
|
||||
'padding': 5,
|
||||
'number_next': 1,
|
||||
'number_increment': 1
|
||||
})
|
||||
if unbuild_vals:
|
||||
self.env['ir.sequence'].create(unbuild_vals)
|
||||
|
||||
@api.model
|
||||
def create_missing_unbuild_sequences(self):
|
||||
company_ids = self.env['res.company'].search([])
|
||||
company_has_unbuild_seq = self.env['ir.sequence'].search([('code', '=', 'mrp.unbuild')]).mapped('company_id')
|
||||
company_todo_sequence = company_ids - company_has_unbuild_seq
|
||||
company_todo_sequence._create_unbuild_sequence()
|
||||
|
||||
def _create_per_company_sequences(self):
|
||||
super(Company, self)._create_per_company_sequences()
|
||||
self._create_unbuild_sequence()
|
||||
|
||||
def _get_security_by_rule_action(self):
|
||||
res = super()._get_security_by_rule_action()
|
||||
res['manufacture'] = self.manufacturing_lead
|
||||
return res
|
||||
65
odoo-bringout-oca-ocb-mrp/mrp/models/res_config_settings.py
Normal file
65
odoo-bringout-oca-ocb-mrp/mrp/models/res_config_settings.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
manufacturing_lead = fields.Float(related='company_id.manufacturing_lead', string="Manufacturing Lead Time", readonly=False)
|
||||
use_manufacturing_lead = fields.Boolean(string="Default Manufacturing Lead Time", config_parameter='mrp.use_manufacturing_lead')
|
||||
group_mrp_byproducts = fields.Boolean("By-Products",
|
||||
implied_group='mrp.group_mrp_byproducts')
|
||||
module_mrp_mps = fields.Boolean("Master Production Schedule")
|
||||
module_mrp_plm = fields.Boolean("Product Lifecycle Management (PLM)")
|
||||
module_mrp_workorder = fields.Boolean("Work Orders")
|
||||
module_quality_control = fields.Boolean("Quality")
|
||||
module_quality_control_worksheet = fields.Boolean("Quality Worksheet")
|
||||
module_mrp_subcontracting = fields.Boolean("Subcontracting")
|
||||
group_mrp_routings = fields.Boolean("MRP Work Orders",
|
||||
implied_group='mrp.group_mrp_routings')
|
||||
group_unlocked_by_default = fields.Boolean("Unlock Manufacturing Orders", implied_group='mrp.group_unlocked_by_default')
|
||||
group_mrp_reception_report = fields.Boolean("Allocation Report for Manufacturing Orders", implied_group='mrp.group_mrp_reception_report')
|
||||
group_mrp_workorder_dependencies = fields.Boolean("Work Order Dependencies", implied_group="mrp.group_mrp_workorder_dependencies")
|
||||
|
||||
def set_values(self):
|
||||
routing_before = self.env.user.has_group('mrp.group_mrp_routings')
|
||||
super().set_values()
|
||||
if routing_before and not self.group_mrp_routings:
|
||||
self.env['mrp.routing.workcenter'].search([]).active = False
|
||||
elif not routing_before and self.group_mrp_routings:
|
||||
operations = self.env['mrp.routing.workcenter'].search_read([('active', '=', False)], ['id', 'write_date'])
|
||||
last_updated = max((op['write_date'] for op in operations), default=0)
|
||||
if last_updated:
|
||||
op_to_update = self.env['mrp.routing.workcenter'].browse([op['id'] for op in operations if op['write_date'] == last_updated])
|
||||
op_to_update.active = True
|
||||
if not self.group_mrp_workorder_dependencies:
|
||||
# Disabling this option should not interfere with currently planned productions
|
||||
self.env['mrp.bom'].sudo().search([('allow_operation_dependencies', '=', True)]).allow_operation_dependencies = False
|
||||
|
||||
@api.onchange('use_manufacturing_lead')
|
||||
def _onchange_use_manufacturing_lead(self):
|
||||
if not self.use_manufacturing_lead:
|
||||
self.manufacturing_lead = 0.0
|
||||
|
||||
@api.onchange('group_mrp_routings')
|
||||
def _onchange_group_mrp_routings(self):
|
||||
# If we activate 'MRP Work Orders', it means that we need to install 'mrp_workorder'.
|
||||
# The opposite is not always true: other modules (such as 'quality_mrp_workorder') may
|
||||
# depend on 'mrp_workorder', so we should not automatically uninstall the module if 'MRP
|
||||
# Work Orders' is deactivated.
|
||||
# Long story short: if 'mrp_workorder' is already installed, we don't uninstall it based on
|
||||
# group_mrp_routings
|
||||
if self.group_mrp_routings:
|
||||
self.module_mrp_workorder = True
|
||||
else:
|
||||
self.module_mrp_workorder = False
|
||||
|
||||
@api.onchange('group_unlocked_by_default')
|
||||
def _onchange_group_unlocked_by_default(self):
|
||||
""" When changing this setting, we want existing MOs to automatically update to match setting. """
|
||||
if self.group_unlocked_by_default:
|
||||
self.env['mrp.production'].search([('state', 'not in', ('cancel', 'done')), ('is_locked', '=', True)]).is_locked = False
|
||||
else:
|
||||
self.env['mrp.production'].search([('state', 'not in', ('cancel', 'done')), ('is_locked', '=', False)]).is_locked = True
|
||||
17
odoo-bringout-oca-ocb-mrp/mrp/models/stock_lot.py
Normal file
17
odoo-bringout-oca-ocb-mrp/mrp/models/stock_lot.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class StockLot(models.Model):
|
||||
_inherit = 'stock.lot'
|
||||
|
||||
def _check_create(self):
|
||||
active_mo_id = self.env.context.get('active_mo_id')
|
||||
if active_mo_id:
|
||||
active_mo = self.env['mrp.production'].browse(active_mo_id)
|
||||
if not active_mo.picking_type_id.use_create_components_lots:
|
||||
raise UserError(_('You are not allowed to create or edit a lot or serial number for the components with the operation type "Manufacturing". To change this, go on the operation type and tick the box "Create New Lots/Serial Numbers for Components".'))
|
||||
return super()._check_create()
|
||||
611
odoo-bringout-oca-ocb-mrp/mrp/models/stock_move.py
Normal file
611
odoo-bringout-oca-ocb-mrp/mrp/models/stock_move.py
Normal file
|
|
@ -0,0 +1,611 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import defaultdict
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from odoo import api, Command, fields, models
|
||||
from odoo.osv import expression
|
||||
from odoo.tools import float_compare, float_round, float_is_zero, OrderedSet
|
||||
|
||||
|
||||
class StockMoveLine(models.Model):
|
||||
_inherit = 'stock.move.line'
|
||||
|
||||
workorder_id = fields.Many2one('mrp.workorder', 'Work Order', check_company=True)
|
||||
production_id = fields.Many2one('mrp.production', 'Production Order', check_company=True)
|
||||
description_bom_line = fields.Char(related='move_id.description_bom_line')
|
||||
|
||||
@api.depends('production_id')
|
||||
def _compute_picking_type_id(self):
|
||||
line_to_remove = self.env['stock.move.line']
|
||||
for line in self:
|
||||
if not line.production_id:
|
||||
continue
|
||||
line.picking_type_id = line.production_id.picking_type_id
|
||||
line_to_remove |= line
|
||||
return super(StockMoveLine, self - line_to_remove)._compute_picking_type_id()
|
||||
|
||||
def _search_picking_type_id(self, operator, value):
|
||||
res = super()._search_picking_type_id(operator=operator, value=value)
|
||||
if operator in ['not in', '!=', 'not ilike']:
|
||||
if value is False:
|
||||
return expression.OR([[('production_id.picking_type_id', operator, value)], res])
|
||||
else:
|
||||
return expression.AND([[('production_id.picking_type_id', operator, value)], res])
|
||||
else:
|
||||
if value is False:
|
||||
return expression.AND([[('production_id.picking_type_id', operator, value)], res])
|
||||
else:
|
||||
return expression.OR([[('production_id.picking_type_id', operator, value)], res])
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, values):
|
||||
res = super(StockMoveLine, self).create(values)
|
||||
for line in res:
|
||||
# If the line is added in a done production, we need to map it
|
||||
# manually to the produced move lines in order to see them in the
|
||||
# traceability report
|
||||
if line.move_id.raw_material_production_id and line.state == 'done':
|
||||
mo = line.move_id.raw_material_production_id
|
||||
finished_lots = mo.lot_producing_id
|
||||
finished_lots |= mo.move_finished_ids.filtered(lambda m: m.product_id != mo.product_id).move_line_ids.lot_id
|
||||
if finished_lots:
|
||||
produced_move_lines = mo.move_finished_ids.move_line_ids.filtered(lambda sml: sml.lot_id in finished_lots)
|
||||
line.produce_line_ids = [(6, 0, produced_move_lines.ids)]
|
||||
else:
|
||||
produced_move_lines = mo.move_finished_ids.move_line_ids
|
||||
line.produce_line_ids = [(6, 0, produced_move_lines.ids)]
|
||||
return res
|
||||
|
||||
def _get_similar_move_lines(self):
|
||||
lines = super(StockMoveLine, self)._get_similar_move_lines()
|
||||
if self.move_id.production_id:
|
||||
finished_moves = self.move_id.production_id.move_finished_ids
|
||||
finished_move_lines = finished_moves.mapped('move_line_ids')
|
||||
lines |= finished_move_lines.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name))
|
||||
if self.move_id.raw_material_production_id:
|
||||
raw_moves = self.move_id.raw_material_production_id.move_raw_ids
|
||||
raw_moves_lines = raw_moves.mapped('move_line_ids')
|
||||
lines |= raw_moves_lines.filtered(lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name))
|
||||
return lines
|
||||
|
||||
def _reservation_is_updatable(self, quantity, reserved_quant):
|
||||
self.ensure_one()
|
||||
if self.produce_line_ids.lot_id:
|
||||
ml_remaining_qty = self.qty_done - self.reserved_uom_qty
|
||||
ml_remaining_qty = self.product_uom_id._compute_quantity(ml_remaining_qty, self.product_id.uom_id, rounding_method="HALF-UP")
|
||||
if float_compare(ml_remaining_qty, quantity, precision_rounding=self.product_id.uom_id.rounding) < 0:
|
||||
return False
|
||||
return super(StockMoveLine, self)._reservation_is_updatable(quantity, reserved_quant)
|
||||
|
||||
def write(self, vals):
|
||||
for move_line in self:
|
||||
production = move_line.move_id.production_id or move_line.move_id.raw_material_production_id
|
||||
if production and move_line.state == 'done' and any(field in vals for field in ('lot_id', 'location_id', 'qty_done')):
|
||||
move_line._log_message(production, move_line, 'mrp.track_production_move_template', vals)
|
||||
return super(StockMoveLine, self).write(vals)
|
||||
|
||||
def _get_aggregated_properties(self, move_line=False, move=False):
|
||||
aggregated_properties = super()._get_aggregated_properties(move_line, move)
|
||||
bom = aggregated_properties['move'].bom_line_id.bom_id
|
||||
aggregated_properties['bom'] = bom or False
|
||||
aggregated_properties['line_key'] += f'_{bom.id if bom else ""}'
|
||||
return aggregated_properties
|
||||
|
||||
def _get_aggregated_product_quantities(self, **kwargs):
|
||||
"""Returns dictionary of products and corresponding values of interest grouped by optional kit_name
|
||||
|
||||
Removes descriptions where description == kit_name. kit_name is expected to be passed as a
|
||||
kwargs value because this is not directly stored in move_line_ids. Unfortunately because we
|
||||
are working with aggregated data, we have to loop through the aggregation to do this removal.
|
||||
|
||||
arguments: kit_name (optional): string value of a kit name passed as a kwarg
|
||||
returns: dictionary {same_key_as_super: {same_values_as_super, ...}
|
||||
"""
|
||||
aggregated_move_lines = super()._get_aggregated_product_quantities(**kwargs)
|
||||
kit_name = kwargs.get('kit_name')
|
||||
|
||||
to_be_removed = []
|
||||
for aggregated_move_line in aggregated_move_lines:
|
||||
bom = aggregated_move_lines[aggregated_move_line]['bom']
|
||||
is_phantom = bom.type == 'phantom' if bom else False
|
||||
if kit_name:
|
||||
product = bom.product_id or bom.product_tmpl_id if bom else False
|
||||
display_name = product.display_name if product else False
|
||||
description = aggregated_move_lines[aggregated_move_line]['description']
|
||||
if not is_phantom or display_name != kit_name:
|
||||
to_be_removed.append(aggregated_move_line)
|
||||
elif description == kit_name:
|
||||
aggregated_move_lines[aggregated_move_line]['description'] = ""
|
||||
elif not kwargs and is_phantom:
|
||||
to_be_removed.append(aggregated_move_line)
|
||||
|
||||
for move_line in to_be_removed:
|
||||
del aggregated_move_lines[move_line]
|
||||
|
||||
return aggregated_move_lines
|
||||
|
||||
|
||||
class StockMove(models.Model):
|
||||
_inherit = 'stock.move'
|
||||
|
||||
created_production_id = fields.Many2one('mrp.production', 'Created Production Order', check_company=True, index=True)
|
||||
production_id = fields.Many2one(
|
||||
'mrp.production', 'Production Order for finished products', check_company=True, index='btree_not_null')
|
||||
raw_material_production_id = fields.Many2one(
|
||||
'mrp.production', 'Production Order for components', check_company=True, index='btree_not_null')
|
||||
unbuild_id = fields.Many2one(
|
||||
'mrp.unbuild', 'Disassembly Order', check_company=True)
|
||||
consume_unbuild_id = fields.Many2one(
|
||||
'mrp.unbuild', 'Consumed Disassembly Order', check_company=True)
|
||||
allowed_operation_ids = fields.One2many(
|
||||
'mrp.routing.workcenter', related='raw_material_production_id.bom_id.operation_ids')
|
||||
operation_id = fields.Many2one(
|
||||
'mrp.routing.workcenter', 'Operation To Consume', check_company=True,
|
||||
domain="[('id', 'in', allowed_operation_ids)]")
|
||||
workorder_id = fields.Many2one(
|
||||
'mrp.workorder', 'Work Order To Consume', copy=False, check_company=True)
|
||||
# Quantities to process, in normalized UoMs
|
||||
bom_line_id = fields.Many2one('mrp.bom.line', 'BoM Line', check_company=True)
|
||||
byproduct_id = fields.Many2one(
|
||||
'mrp.bom.byproduct', 'By-products', check_company=True,
|
||||
help="By-product line that generated the move in a manufacturing order")
|
||||
unit_factor = fields.Float('Unit Factor', compute='_compute_unit_factor', store=True)
|
||||
is_done = fields.Boolean(
|
||||
'Done', compute='_compute_is_done', store=True)
|
||||
order_finished_lot_id = fields.Many2one('stock.lot', string="Finished Lot/Serial Number", related="raw_material_production_id.lot_producing_id", store=True, index='btree_not_null')
|
||||
should_consume_qty = fields.Float('Quantity To Consume', compute='_compute_should_consume_qty', digits='Product Unit of Measure')
|
||||
cost_share = fields.Float(
|
||||
"Cost Share (%)", digits=(5, 2), # decimal = 2 is important for rounding calculations!!
|
||||
help="The percentage of the final production cost for this by-product. The total of all by-products' cost share must be smaller or equal to 100.")
|
||||
product_qty_available = fields.Float('Product On Hand Quantity', related='product_id.qty_available', depends=['product_id'])
|
||||
product_virtual_available = fields.Float('Product Forecasted Quantity', related='product_id.virtual_available', depends=['product_id'])
|
||||
description_bom_line = fields.Char('Kit', compute='_compute_description_bom_line')
|
||||
manual_consumption = fields.Boolean(
|
||||
'Manual Consumption', compute='_compute_manual_consumption', store=True,
|
||||
help="When activated, then the registration of consumption for that component is recorded manually exclusively.\n"
|
||||
"If not activated, and any of the components consumption is edited manually on the manufacturing order, Odoo assumes manual consumption also.")
|
||||
|
||||
@api.depends('state', 'product_id', 'operation_id')
|
||||
def _compute_manual_consumption(self):
|
||||
for move in self:
|
||||
if move.state != 'draft':
|
||||
continue
|
||||
move.manual_consumption = move._is_manual_consumption()
|
||||
|
||||
@api.depends('bom_line_id')
|
||||
def _compute_description_bom_line(self):
|
||||
bom_line_description = {}
|
||||
for bom in self.bom_line_id.bom_id:
|
||||
if bom.type != 'phantom':
|
||||
continue
|
||||
line_ids = bom.bom_line_ids.ids
|
||||
total = len(line_ids)
|
||||
name = bom.display_name
|
||||
for i, line_id in enumerate(line_ids):
|
||||
bom_line_description[line_id] = '%s - %d/%d' % (name, i+1, total)
|
||||
|
||||
for move in self:
|
||||
move.description_bom_line = bom_line_description.get(move.bom_line_id.id)
|
||||
|
||||
@api.depends('raw_material_production_id.priority')
|
||||
def _compute_priority(self):
|
||||
super()._compute_priority()
|
||||
for move in self:
|
||||
move.priority = move.raw_material_production_id.priority or move.priority or '0'
|
||||
|
||||
@api.depends('raw_material_production_id.picking_type_id', 'production_id.picking_type_id')
|
||||
def _compute_picking_type_id(self):
|
||||
super()._compute_picking_type_id()
|
||||
for move in self:
|
||||
if move.raw_material_production_id or move.production_id:
|
||||
move.picking_type_id = (move.raw_material_production_id or move.production_id).picking_type_id
|
||||
|
||||
@api.depends('raw_material_production_id.is_locked', 'production_id.is_locked')
|
||||
def _compute_is_locked(self):
|
||||
super(StockMove, self)._compute_is_locked()
|
||||
for move in self:
|
||||
if move.raw_material_production_id:
|
||||
move.is_locked = move.raw_material_production_id.is_locked
|
||||
if move.production_id:
|
||||
move.is_locked = move.production_id.is_locked
|
||||
|
||||
@api.depends('state')
|
||||
def _compute_is_done(self):
|
||||
for move in self:
|
||||
move.is_done = (move.state in ('done', 'cancel'))
|
||||
|
||||
@api.depends('product_uom_qty',
|
||||
'raw_material_production_id', 'raw_material_production_id.product_qty', 'raw_material_production_id.qty_produced',
|
||||
'production_id', 'production_id.product_qty', 'production_id.qty_produced')
|
||||
def _compute_unit_factor(self):
|
||||
for move in self:
|
||||
mo = move.raw_material_production_id or move.production_id
|
||||
if mo:
|
||||
move.unit_factor = move.product_uom_qty / ((mo.product_qty - mo.qty_produced) or 1)
|
||||
else:
|
||||
move.unit_factor = 1.0
|
||||
|
||||
@api.depends('raw_material_production_id', 'raw_material_production_id.name', 'production_id', 'production_id.name')
|
||||
def _compute_reference(self):
|
||||
moves_with_reference = self.env['stock.move']
|
||||
for move in self:
|
||||
if move.raw_material_production_id and move.raw_material_production_id.name:
|
||||
move.reference = move.raw_material_production_id.name
|
||||
moves_with_reference |= move
|
||||
if move.production_id and move.production_id.name:
|
||||
move.reference = move.production_id.name
|
||||
moves_with_reference |= move
|
||||
super(StockMove, self - moves_with_reference)._compute_reference()
|
||||
|
||||
@api.depends('raw_material_production_id.qty_producing', 'product_uom_qty', 'product_uom')
|
||||
def _compute_should_consume_qty(self):
|
||||
for move in self:
|
||||
mo = move.raw_material_production_id
|
||||
if not mo or not move.product_uom:
|
||||
move.should_consume_qty = 0
|
||||
continue
|
||||
move.should_consume_qty = float_round((mo.qty_producing - mo.qty_produced) * move.unit_factor, precision_rounding=move.product_uom.rounding)
|
||||
|
||||
@api.onchange('product_uom_qty')
|
||||
def _onchange_product_uom_qty(self):
|
||||
if self.raw_material_production_id and self.has_tracking == 'none':
|
||||
mo = self.raw_material_production_id
|
||||
self._update_quantity_done(mo)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
defaults = super(StockMove, self).default_get(fields_list)
|
||||
if self.env.context.get('default_raw_material_production_id') or self.env.context.get('default_production_id'):
|
||||
production_id = self.env['mrp.production'].browse(self.env.context.get('default_raw_material_production_id') or self.env.context.get('default_production_id'))
|
||||
if production_id.state not in ('draft', 'cancel'):
|
||||
if production_id.state != 'done':
|
||||
defaults['state'] = 'draft'
|
||||
else:
|
||||
defaults['state'] = 'done'
|
||||
defaults['additional'] = True
|
||||
defaults['product_uom_qty'] = 0.0
|
||||
elif production_id.state == 'draft':
|
||||
defaults['group_id'] = production_id.procurement_group_id.id
|
||||
defaults['reference'] = production_id.name
|
||||
return defaults
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
""" Enforce consistent values (i.e. match _get_move_raw_values/_get_move_finished_values) for:
|
||||
- Manually added components/byproducts specifically values we can't set via view with "default_"
|
||||
- Moves from a copied MO
|
||||
- Backorders
|
||||
"""
|
||||
if self.env.context.get('force_manual_consumption'):
|
||||
for vals in vals_list:
|
||||
vals['manual_consumption'] = True
|
||||
mo_id_to_mo = defaultdict(lambda: self.env['mrp.production'])
|
||||
product_id_to_product = defaultdict(lambda: self.env['product.product'])
|
||||
for values in vals_list:
|
||||
mo_id = values.get('raw_material_production_id', False) or values.get('production_id', False)
|
||||
location_dest = self.env['stock.location'].browse(values.get('location_dest_id'))
|
||||
if mo_id and not values.get('scrapped') and not location_dest.scrap_location:
|
||||
mo = mo_id_to_mo[mo_id]
|
||||
if not mo:
|
||||
mo = mo.browse(mo_id)
|
||||
mo_id_to_mo[mo_id] = mo
|
||||
values['name'] = mo.name
|
||||
values['origin'] = mo._get_origin()
|
||||
values['group_id'] = mo.procurement_group_id.id
|
||||
values['propagate_cancel'] = mo.propagate_cancel
|
||||
if values.get('raw_material_production_id', False):
|
||||
product = product_id_to_product[values['product_id']]
|
||||
if not product:
|
||||
product = product.browse(values['product_id'])
|
||||
product_id_to_product[values['product_id']] = product
|
||||
values['location_dest_id'] = mo.production_location_id.id
|
||||
values['price_unit'] = product.standard_price
|
||||
if not values.get('location_id'):
|
||||
values['location_id'] = mo.location_src_id.id
|
||||
continue
|
||||
# produced products + byproducts
|
||||
values['location_id'] = mo.production_location_id.id
|
||||
values['date'] = mo._get_date_planned_finished()
|
||||
values['date_deadline'] = mo.date_deadline
|
||||
if not values.get('location_dest_id'):
|
||||
values['location_dest_id'] = mo.location_dest_id.id
|
||||
return super().create(vals_list)
|
||||
|
||||
def write(self, vals):
|
||||
if self.env.context.get('force_manual_consumption'):
|
||||
vals['manual_consumption'] = True
|
||||
if 'product_uom_qty' in vals:
|
||||
if 'move_line_ids' in vals:
|
||||
# first update lines then product_uom_qty as the later will unreserve
|
||||
# so possibly unlink lines
|
||||
move_line_vals = vals.pop('move_line_ids')
|
||||
super().write({'move_line_ids': move_line_vals})
|
||||
procurement_requests = []
|
||||
for move in self:
|
||||
if move.raw_material_production_id.state != 'confirmed' \
|
||||
or not float_is_zero(move.product_uom_qty, precision_rounding=move.product_uom.rounding) \
|
||||
or move.procure_method != 'make_to_order':
|
||||
continue
|
||||
values = move._prepare_procurement_values()
|
||||
origin = move._prepare_procurement_origin()
|
||||
procurement_requests.append(self.env['procurement.group'].Procurement(
|
||||
move.product_id, vals['product_uom_qty'], move.product_uom,
|
||||
move.location_id, move.rule_id and move.rule_id.name or "/",
|
||||
origin, move.company_id, values))
|
||||
self.env['procurement.group'].run(procurement_requests)
|
||||
return super().write(vals)
|
||||
|
||||
def _action_assign(self, force_qty=False):
|
||||
res = super(StockMove, self)._action_assign(force_qty=force_qty)
|
||||
for move in self.filtered(lambda x: x.production_id or x.raw_material_production_id):
|
||||
if move.move_line_ids:
|
||||
move.move_line_ids.write({'production_id': move.raw_material_production_id.id,
|
||||
'workorder_id': move.workorder_id.id,})
|
||||
return res
|
||||
|
||||
def _action_confirm(self, merge=True, merge_into=False):
|
||||
moves = self.action_explode()
|
||||
merge_into = merge_into and merge_into.action_explode()
|
||||
# we go further with the list of ids potentially changed by action_explode
|
||||
return super(StockMove, moves)._action_confirm(merge=merge, merge_into=merge_into)
|
||||
|
||||
def action_explode(self):
|
||||
""" Explodes pickings """
|
||||
# in order to explode a move, we must have a picking_type_id on that move because otherwise the move
|
||||
# won't be assigned to a picking and it would be weird to explode a move into several if they aren't
|
||||
# all grouped in the same picking.
|
||||
moves_ids_to_return = OrderedSet()
|
||||
moves_ids_to_unlink = OrderedSet()
|
||||
phantom_moves_vals_list = []
|
||||
for move in self:
|
||||
if not move.picking_type_id or (move.production_id and move.production_id.product_id == move.product_id):
|
||||
moves_ids_to_return.add(move.id)
|
||||
continue
|
||||
bom = self.env['mrp.bom'].sudo()._bom_find(move.product_id, company_id=move.company_id.id, bom_type='phantom')[move.product_id]
|
||||
if not bom:
|
||||
moves_ids_to_return.add(move.id)
|
||||
continue
|
||||
if move.picking_id.immediate_transfer or float_is_zero(move.product_uom_qty, precision_rounding=move.product_uom.rounding):
|
||||
factor = move.product_uom._compute_quantity(move.quantity_done, bom.product_uom_id) / bom.product_qty
|
||||
else:
|
||||
factor = move.product_uom._compute_quantity(move.product_uom_qty, bom.product_uom_id) / bom.product_qty
|
||||
boms, lines = bom.sudo().explode(move.product_id, factor, picking_type=bom.picking_type_id)
|
||||
for bom_line, line_data in lines:
|
||||
if move.picking_id.immediate_transfer or float_is_zero(move.product_uom_qty, precision_rounding=move.product_uom.rounding):
|
||||
phantom_moves_vals_list += move._generate_move_phantom(bom_line, 0, line_data['qty'])
|
||||
else:
|
||||
phantom_moves_vals_list += move._generate_move_phantom(bom_line, line_data['qty'], 0)
|
||||
# delete the move with original product which is not relevant anymore
|
||||
moves_ids_to_unlink.add(move.id)
|
||||
|
||||
if phantom_moves_vals_list:
|
||||
phantom_moves = self.env['stock.move'].create(phantom_moves_vals_list)
|
||||
phantom_moves._adjust_procure_method()
|
||||
moves_ids_to_return |= phantom_moves.action_explode().ids
|
||||
move_to_unlink = self.env['stock.move'].browse(moves_ids_to_unlink).sudo()
|
||||
move_to_unlink.quantity_done = 0
|
||||
move_to_unlink._action_cancel()
|
||||
move_to_unlink.unlink()
|
||||
return self.env['stock.move'].browse(moves_ids_to_return)
|
||||
|
||||
def action_show_details(self):
|
||||
self.ensure_one()
|
||||
action = super().action_show_details()
|
||||
if self.raw_material_production_id:
|
||||
action['views'] = [(self.env.ref('mrp.view_stock_move_operations_raw').id, 'form')]
|
||||
action['context']['show_destination_location'] = False
|
||||
action['context']['force_manual_consumption'] = True
|
||||
action['context']['active_mo_id'] = self.raw_material_production_id.id
|
||||
elif self.production_id:
|
||||
action['views'] = [(self.env.ref('mrp.view_stock_move_operations_finished').id, 'form')]
|
||||
action['context']['show_source_location'] = False
|
||||
action['context']['show_reserved_quantity'] = False
|
||||
return action
|
||||
|
||||
def _action_cancel(self):
|
||||
res = super(StockMove, self)._action_cancel()
|
||||
mo_to_cancel = self.mapped('raw_material_production_id').filtered(lambda p: all(m.state == 'cancel' for m in p.move_raw_ids))
|
||||
if mo_to_cancel:
|
||||
mo_to_cancel._action_cancel()
|
||||
return res
|
||||
|
||||
def _prepare_move_split_vals(self, qty):
|
||||
defaults = super()._prepare_move_split_vals(qty)
|
||||
defaults['workorder_id'] = False
|
||||
return defaults
|
||||
|
||||
def _prepare_procurement_origin(self):
|
||||
self.ensure_one()
|
||||
if self.raw_material_production_id and self.raw_material_production_id.orderpoint_id:
|
||||
return self.origin
|
||||
return super()._prepare_procurement_origin()
|
||||
|
||||
def _prepare_phantom_move_values(self, bom_line, product_qty, quantity_done):
|
||||
return {
|
||||
'picking_id': self.picking_id.id if self.picking_id else False,
|
||||
'product_id': bom_line.product_id.id,
|
||||
'product_uom': bom_line.product_uom_id.id,
|
||||
'product_uom_qty': product_qty,
|
||||
'quantity_done': quantity_done,
|
||||
'state': 'draft', # will be confirmed below
|
||||
'name': self.name,
|
||||
'bom_line_id': bom_line.id,
|
||||
}
|
||||
|
||||
def _generate_move_phantom(self, bom_line, product_qty, quantity_done):
|
||||
vals = []
|
||||
if bom_line.product_id.type in ['product', 'consu']:
|
||||
vals = self.copy_data(default=self._prepare_phantom_move_values(bom_line, product_qty, quantity_done))
|
||||
if self.state == 'assigned':
|
||||
for v in vals:
|
||||
v['state'] = 'assigned'
|
||||
return vals
|
||||
|
||||
def _is_consuming(self):
|
||||
return super()._is_consuming() or self.picking_type_id.code == 'mrp_operation'
|
||||
|
||||
def _get_backorder_move_vals(self):
|
||||
self.ensure_one()
|
||||
return {
|
||||
'state': 'draft' if self.state == 'draft' else 'confirmed',
|
||||
'reservation_date': self.reservation_date,
|
||||
'date_deadline': self.date_deadline,
|
||||
'manual_consumption': self._is_manual_consumption(),
|
||||
'move_orig_ids': [Command.link(m.id) for m in self.mapped('move_orig_ids')],
|
||||
'move_dest_ids': [Command.link(m.id) for m in self.mapped('move_dest_ids')],
|
||||
'procure_method': self.procure_method,
|
||||
}
|
||||
|
||||
def _get_source_document(self):
|
||||
res = super()._get_source_document()
|
||||
return res or self.production_id or self.raw_material_production_id
|
||||
|
||||
def _get_upstream_documents_and_responsibles(self, visited):
|
||||
if self.production_id and self.production_id.state not in ('done', 'cancel'):
|
||||
return [(self.production_id, self.production_id.user_id, visited)]
|
||||
else:
|
||||
return super(StockMove, self)._get_upstream_documents_and_responsibles(visited)
|
||||
|
||||
def _delay_alert_get_documents(self):
|
||||
res = super(StockMove, self)._delay_alert_get_documents()
|
||||
productions = self.raw_material_production_id | self.production_id
|
||||
return res + list(productions)
|
||||
|
||||
def _should_be_assigned(self):
|
||||
res = super(StockMove, self)._should_be_assigned()
|
||||
return bool(res and not (self.production_id or self.raw_material_production_id))
|
||||
|
||||
def _should_bypass_set_qty_producing(self):
|
||||
if self.state in ('done', 'cancel'):
|
||||
return True
|
||||
# Do not update extra product quantities
|
||||
if float_is_zero(self.product_uom_qty, precision_rounding=self.product_uom.rounding):
|
||||
return True
|
||||
if (not self.raw_material_production_id.use_auto_consume_components_lots and self.has_tracking != 'none') or self.manual_consumption or self._origin.manual_consumption:
|
||||
return True
|
||||
return False
|
||||
|
||||
def _should_bypass_reservation(self, forced_location=False):
|
||||
res = super(StockMove, self)._should_bypass_reservation(
|
||||
forced_location=forced_location)
|
||||
return bool(res and not self.production_id)
|
||||
|
||||
def _key_assign_picking(self):
|
||||
keys = super(StockMove, self)._key_assign_picking()
|
||||
return keys + (self.created_production_id,)
|
||||
|
||||
@api.model
|
||||
def _prepare_merge_moves_distinct_fields(self):
|
||||
res = super()._prepare_merge_moves_distinct_fields()
|
||||
res += ['created_production_id', 'cost_share']
|
||||
if self.bom_line_id and ("phantom" in self.bom_line_id.bom_id.mapped('type')):
|
||||
res.append('bom_line_id')
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def _prepare_merge_negative_moves_excluded_distinct_fields(self):
|
||||
return super()._prepare_merge_negative_moves_excluded_distinct_fields() + ['created_production_id']
|
||||
|
||||
def _compute_kit_quantities(self, product_id, kit_qty, kit_bom, filters):
|
||||
""" Computes the quantity delivered or received when a kit is sold or purchased.
|
||||
A ratio 'qty_processed/qty_needed' is computed for each component, and the lowest one is kept
|
||||
to define the kit's quantity delivered or received.
|
||||
:param product_id: The kit itself a.k.a. the finished product
|
||||
:param kit_qty: The quantity from the order line
|
||||
:param kit_bom: The kit's BoM
|
||||
:param filters: Dict of lambda expression to define the moves to consider and the ones to ignore
|
||||
:return: The quantity delivered or received
|
||||
"""
|
||||
qty_ratios = []
|
||||
boms, bom_sub_lines = kit_bom.explode(product_id, kit_qty)
|
||||
for bom_line, bom_line_data in bom_sub_lines:
|
||||
# skip service since we never deliver them
|
||||
if bom_line.product_id.type == 'service':
|
||||
continue
|
||||
if float_is_zero(bom_line_data['qty'], precision_rounding=bom_line.product_uom_id.rounding):
|
||||
# As BoMs allow components with 0 qty, a.k.a. optionnal components, we simply skip those
|
||||
# to avoid a division by zero.
|
||||
continue
|
||||
bom_line_moves = self.filtered(lambda m: m.bom_line_id == bom_line)
|
||||
if bom_line_moves:
|
||||
# We compute the quantities needed of each components to make one kit.
|
||||
# Then, we collect every relevant moves related to a specific component
|
||||
# to know how many are considered delivered.
|
||||
uom_qty_per_kit = bom_line_data['qty'] / bom_line_data['original_qty']
|
||||
qty_per_kit = bom_line.product_uom_id._compute_quantity(uom_qty_per_kit, bom_line.product_id.uom_id, round=False)
|
||||
if not qty_per_kit:
|
||||
continue
|
||||
incoming_moves = bom_line_moves.filtered(filters['incoming_moves'])
|
||||
outgoing_moves = bom_line_moves.filtered(filters['outgoing_moves'])
|
||||
qty_processed = sum(incoming_moves.mapped('product_qty')) - sum(outgoing_moves.mapped('product_qty'))
|
||||
# We compute a ratio to know how many kits we can produce with this quantity of that specific component
|
||||
qty_ratios.append(float_round(qty_processed / qty_per_kit, precision_rounding=bom_line.product_id.uom_id.rounding))
|
||||
else:
|
||||
return 0.0
|
||||
if qty_ratios:
|
||||
# Now that we have every ratio by components, we keep the lowest one to know how many kits we can produce
|
||||
# with the quantities delivered of each component. We use the floor division here because a 'partial kit'
|
||||
# doesn't make sense.
|
||||
return min(qty_ratios) // 1
|
||||
else:
|
||||
return 0.0
|
||||
|
||||
def _show_details_in_draft(self):
|
||||
self.ensure_one()
|
||||
production = self.raw_material_production_id or self.production_id
|
||||
if production and (self.state != 'draft' or production.state != 'draft'):
|
||||
return True
|
||||
elif production:
|
||||
return False
|
||||
else:
|
||||
return super()._show_details_in_draft()
|
||||
|
||||
def _update_quantity_done(self, mo):
|
||||
self.ensure_one()
|
||||
new_qty = float_round((mo.qty_producing - mo.qty_produced) * self.unit_factor, precision_rounding=self.product_uom.rounding)
|
||||
if not self.is_quantity_done_editable:
|
||||
self.move_line_ids.filtered(lambda ml: ml.state not in ('done', 'cancel')).qty_done = 0
|
||||
self.move_line_ids = self._set_quantity_done_prepare_vals(new_qty)
|
||||
else:
|
||||
self.quantity_done = new_qty
|
||||
|
||||
def _update_candidate_moves_list(self, candidate_moves_list):
|
||||
super()._update_candidate_moves_list(candidate_moves_list)
|
||||
for production in self.mapped('raw_material_production_id'):
|
||||
candidate_moves_list.append(production.move_raw_ids.filtered(lambda m: m.product_id in self.product_id))
|
||||
for production in self.mapped('production_id'):
|
||||
candidate_moves_list.append(production.move_finished_ids.filtered(lambda m: m.product_id in self.product_id))
|
||||
|
||||
def _multi_line_quantity_done_set(self, quantity_done):
|
||||
if self.raw_material_production_id:
|
||||
self.move_line_ids.filtered(lambda ml: ml.state not in ('done', 'cancel')).qty_done = 0
|
||||
self.move_line_ids = self._set_quantity_done_prepare_vals(quantity_done)
|
||||
else:
|
||||
super()._multi_line_quantity_done_set(quantity_done)
|
||||
|
||||
def _prepare_procurement_values(self):
|
||||
res = super()._prepare_procurement_values()
|
||||
res['bom_line_id'] = self.bom_line_id.id
|
||||
return res
|
||||
|
||||
def action_open_reference(self):
|
||||
res = super().action_open_reference()
|
||||
source = self.production_id or self.raw_material_production_id
|
||||
if source and source.check_access_rights('read', raise_exception=False):
|
||||
return {
|
||||
'res_model': source._name,
|
||||
'type': 'ir.actions.act_window',
|
||||
'views': [[False, "form"]],
|
||||
'res_id': source.id,
|
||||
}
|
||||
return res
|
||||
|
||||
def _is_manual_consumption(self):
|
||||
self.ensure_one()
|
||||
return self._determine_is_manual_consumption(self.product_id, self.raw_material_production_id, self.bom_line_id)
|
||||
|
||||
@api.model
|
||||
def _determine_is_manual_consumption(self, product, production, bom_line):
|
||||
return (product.product_tmpl_id.tracking != 'none' and not production.use_auto_consume_components_lots) or \
|
||||
(product.product_tmpl_id.tracking == 'none' and bom_line and bom_line.manual_consumption)
|
||||
154
odoo-bringout-oca-ocb-mrp/mrp/models/stock_orderpoint.py
Normal file
154
odoo-bringout-oca-ocb-mrp/mrp/models/stock_orderpoint.py
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.tools.float_utils import float_is_zero
|
||||
from odoo.osv.expression import AND
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
|
||||
class StockWarehouseOrderpoint(models.Model):
|
||||
_inherit = 'stock.warehouse.orderpoint'
|
||||
|
||||
show_bom = fields.Boolean('Show BoM column', compute='_compute_show_bom')
|
||||
bom_id = fields.Many2one(
|
||||
'mrp.bom', string='Bill of Materials', check_company=True,
|
||||
domain="[('type', '=', 'normal'), '&', '|', ('company_id', '=', company_id), ('company_id', '=', False), '|', ('product_id', '=', product_id), '&', ('product_id', '=', False), ('product_tmpl_id', '=', product_tmpl_id)]")
|
||||
manufacturing_visibility_days = fields.Float(default=0.0, help="Visibility Days applied on the manufacturing routes.")
|
||||
|
||||
def _get_replenishment_order_notification(self):
|
||||
self.ensure_one()
|
||||
domain = [('orderpoint_id', 'in', self.ids)]
|
||||
if self.env.context.get('written_after'):
|
||||
domain = AND([domain, [('write_date', '>', self.env.context.get('written_after'))]])
|
||||
production = self.env['mrp.production'].search(domain, limit=1)
|
||||
if production:
|
||||
action = self.env.ref('mrp.action_mrp_production_form')
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('The following replenishment order has been generated'),
|
||||
'message': '%s',
|
||||
'links': [{
|
||||
'label': production.name,
|
||||
'url': f'#action={action.id}&id={production.id}&model=mrp.production'
|
||||
}],
|
||||
'sticky': False,
|
||||
'next': {'type': 'ir.actions.act_window_close'},
|
||||
}
|
||||
}
|
||||
return super()._get_replenishment_order_notification()
|
||||
|
||||
@api.depends('route_id')
|
||||
def _compute_show_bom(self):
|
||||
manufacture_route = []
|
||||
for res in self.env['stock.rule'].search_read([('action', '=', 'manufacture')], ['route_id']):
|
||||
manufacture_route.append(res['route_id'][0])
|
||||
for orderpoint in self:
|
||||
orderpoint.show_bom = orderpoint.route_id.id in manufacture_route
|
||||
|
||||
def _compute_visibility_days(self):
|
||||
res = super()._compute_visibility_days()
|
||||
for orderpoint in self:
|
||||
if 'manufacture' in orderpoint.rule_ids.mapped('action'):
|
||||
orderpoint.visibility_days = orderpoint.manufacturing_visibility_days
|
||||
return res
|
||||
|
||||
def _set_visibility_days(self):
|
||||
res = super()._set_visibility_days()
|
||||
for orderpoint in self:
|
||||
if 'manufacture' in orderpoint.rule_ids.mapped('action'):
|
||||
orderpoint.manufacturing_visibility_days = orderpoint.visibility_days
|
||||
return res
|
||||
|
||||
def _compute_days_to_order(self):
|
||||
res = super()._compute_days_to_order()
|
||||
for orderpoint in self:
|
||||
if 'manufacture' in orderpoint.rule_ids.mapped('action'):
|
||||
orderpoint.days_to_order = orderpoint.product_id.days_to_prepare_mo
|
||||
return res
|
||||
|
||||
def _quantity_in_progress(self):
|
||||
bom_kits = self.env['mrp.bom']._bom_find(self.product_id, bom_type='phantom')
|
||||
bom_kit_orderpoints = {
|
||||
orderpoint: bom_kits[orderpoint.product_id]
|
||||
for orderpoint in self
|
||||
if orderpoint.product_id in bom_kits
|
||||
}
|
||||
orderpoints_without_kit = self - self.env['stock.warehouse.orderpoint'].concat(*bom_kit_orderpoints.keys())
|
||||
res = super(StockWarehouseOrderpoint, orderpoints_without_kit)._quantity_in_progress()
|
||||
for orderpoint in bom_kit_orderpoints:
|
||||
dummy, bom_sub_lines = bom_kit_orderpoints[orderpoint].explode(orderpoint.product_id, 1)
|
||||
ratios_qty_available = []
|
||||
# total = qty_available + in_progress
|
||||
ratios_total = []
|
||||
for bom_line, bom_line_data in bom_sub_lines:
|
||||
component = bom_line.product_id
|
||||
if component.type != 'product' or float_is_zero(bom_line_data['qty'], precision_rounding=bom_line.product_uom_id.rounding):
|
||||
continue
|
||||
uom_qty_per_kit = bom_line_data['qty'] / bom_line_data['original_qty']
|
||||
qty_per_kit = bom_line.product_uom_id._compute_quantity(uom_qty_per_kit, bom_line.product_id.uom_id, raise_if_failure=False)
|
||||
if not qty_per_kit:
|
||||
continue
|
||||
qty_by_product_location, dummy = component._get_quantity_in_progress(orderpoint.location_id.ids)
|
||||
qty_in_progress = qty_by_product_location.get((component.id, orderpoint.location_id.id), 0.0)
|
||||
qty_available = component.qty_available / qty_per_kit
|
||||
ratios_qty_available.append(qty_available)
|
||||
ratios_total.append(qty_available + (qty_in_progress / qty_per_kit))
|
||||
# For a kit, the quantity in progress is :
|
||||
# (the quantity if we have received all in-progress components) - (the quantity using only available components)
|
||||
product_qty = min(ratios_total or [0]) - min(ratios_qty_available or [0])
|
||||
res[orderpoint.id] = orderpoint.product_id.uom_id._compute_quantity(product_qty, orderpoint.product_uom, round=False)
|
||||
|
||||
bom_manufacture = self.env['mrp.bom']._bom_find(orderpoints_without_kit.product_id, bom_type='normal')
|
||||
bom_manufacture = self.env['mrp.bom'].concat(*bom_manufacture.values())
|
||||
productions_group = self.env['mrp.production'].read_group(
|
||||
[('bom_id', 'in', bom_manufacture.ids), ('state', '=', 'draft'), ('orderpoint_id', 'in', orderpoints_without_kit.ids)],
|
||||
['orderpoint_id', 'product_qty', 'product_uom_id'],
|
||||
['orderpoint_id', 'product_uom_id'], lazy=False)
|
||||
for p in productions_group:
|
||||
uom = self.env['uom.uom'].browse(p['product_uom_id'][0])
|
||||
orderpoint = self.env['stock.warehouse.orderpoint'].browse(p['orderpoint_id'][0])
|
||||
res[orderpoint.id] += uom._compute_quantity(
|
||||
p['product_qty'], orderpoint.product_uom, round=False)
|
||||
return res
|
||||
|
||||
def _get_qty_multiple_to_order(self):
|
||||
""" Calculates the minimum quantity that can be ordered according to the qty and UoM of the BoM
|
||||
"""
|
||||
self.ensure_one()
|
||||
qty_multiple_to_order = super()._get_qty_multiple_to_order()
|
||||
if 'manufacture' in self.rule_ids.mapped('action'):
|
||||
bom = self.env['mrp.bom']._bom_find(self.product_id, bom_type='normal')[self.product_id]
|
||||
return bom.product_uom_id._compute_quantity(bom.product_qty, self.product_uom)
|
||||
return qty_multiple_to_order
|
||||
|
||||
def _set_default_route_id(self):
|
||||
route_ids = self.env['stock.rule'].search([
|
||||
('action', '=', 'manufacture')
|
||||
]).route_id
|
||||
for orderpoint in self:
|
||||
if not orderpoint.product_id.bom_ids:
|
||||
continue
|
||||
route_id = orderpoint.rule_ids.route_id & route_ids
|
||||
if not route_id:
|
||||
continue
|
||||
orderpoint.route_id = route_id[0].id
|
||||
return super()._set_default_route_id()
|
||||
|
||||
def _prepare_procurement_values(self, date=False, group=False):
|
||||
values = super()._prepare_procurement_values(date=date, group=group)
|
||||
values['bom_id'] = self.bom_id
|
||||
return values
|
||||
|
||||
def _post_process_scheduler(self):
|
||||
""" Confirm the productions only after all the orderpoints have run their
|
||||
procurement to avoid the new procurement created from the production conflict
|
||||
with them. """
|
||||
self.env['mrp.production'].sudo().search([
|
||||
('orderpoint_id', 'in', self.ids),
|
||||
('move_raw_ids', '!=', False),
|
||||
('state', '=', 'draft'),
|
||||
]).action_confirm()
|
||||
return super()._post_process_scheduler()
|
||||
86
odoo-bringout-oca-ocb-mrp/mrp/models/stock_picking.py
Normal file
86
odoo-bringout-oca-ocb-mrp/mrp/models/stock_picking.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class StockPickingType(models.Model):
|
||||
_inherit = 'stock.picking.type'
|
||||
|
||||
code = fields.Selection(selection_add=[
|
||||
('mrp_operation', 'Manufacturing')
|
||||
], ondelete={'mrp_operation': 'cascade'})
|
||||
count_mo_todo = fields.Integer(string="Number of Manufacturing Orders to Process",
|
||||
compute='_get_mo_count')
|
||||
count_mo_waiting = fields.Integer(string="Number of Manufacturing Orders Waiting",
|
||||
compute='_get_mo_count')
|
||||
count_mo_late = fields.Integer(string="Number of Manufacturing Orders Late",
|
||||
compute='_get_mo_count')
|
||||
use_create_components_lots = fields.Boolean(
|
||||
string="Create New Lots/Serial Numbers for Components",
|
||||
help="Allow to create new lot/serial numbers for the components",
|
||||
default=False,
|
||||
)
|
||||
use_auto_consume_components_lots = fields.Boolean(
|
||||
string="Consume Reserved Lots/Serial Numbers automatically",
|
||||
help="Allow automatic consumption of tracked components that are reserved",
|
||||
)
|
||||
|
||||
def _get_mo_count(self):
|
||||
mrp_picking_types = self.filtered(lambda picking: picking.code == 'mrp_operation')
|
||||
if not mrp_picking_types:
|
||||
self.count_mo_waiting = False
|
||||
self.count_mo_todo = False
|
||||
self.count_mo_late = False
|
||||
return
|
||||
domains = {
|
||||
'count_mo_waiting': [('reservation_state', '=', 'waiting')],
|
||||
'count_mo_todo': ['|', ('state', 'in', ('confirmed', 'draft', 'progress', 'to_close')), ('is_planned', '=', True)],
|
||||
'count_mo_late': ['|', ('delay_alert_date', '!=', False), '&', ('date_deadline', '<', fields.Date.today()), ('state', '=', 'confirmed')],
|
||||
}
|
||||
for field in domains:
|
||||
data = self.env['mrp.production']._read_group(domains[field] +
|
||||
[('state', 'not in', ('done', 'cancel')), ('picking_type_id', 'in', self.ids)],
|
||||
['picking_type_id'], ['picking_type_id'])
|
||||
count = {x['picking_type_id'] and x['picking_type_id'][0]: x['picking_type_id_count'] for x in data}
|
||||
for record in mrp_picking_types:
|
||||
record[field] = count.get(record.id, 0)
|
||||
remaining = (self - mrp_picking_types)
|
||||
if remaining:
|
||||
remaining.count_mo_waiting = False
|
||||
remaining.count_mo_todo = False
|
||||
remaining.count_mo_late = False
|
||||
|
||||
def get_mrp_stock_picking_action_picking_type(self):
|
||||
action = self.env["ir.actions.actions"]._for_xml_id('mrp.mrp_production_action_picking_deshboard')
|
||||
if self:
|
||||
action['display_name'] = self.display_name
|
||||
return action
|
||||
|
||||
@api.onchange('code')
|
||||
def _onchange_code(self):
|
||||
if self.code == 'mrp_operation':
|
||||
self.use_create_lots = True
|
||||
self.use_existing_lots = True
|
||||
|
||||
class StockPicking(models.Model):
|
||||
_inherit = 'stock.picking'
|
||||
|
||||
has_kits = fields.Boolean(compute='_compute_has_kits')
|
||||
|
||||
@api.depends('move_ids')
|
||||
def _compute_has_kits(self):
|
||||
for picking in self:
|
||||
picking.has_kits = any(picking.move_ids.mapped('bom_line_id'))
|
||||
|
||||
def _less_quantities_than_expected_add_documents(self, moves, documents):
|
||||
documents = super(StockPicking, self)._less_quantities_than_expected_add_documents(moves, documents)
|
||||
|
||||
def _keys_in_groupby(move):
|
||||
""" group by picking and the responsible for the product the
|
||||
move.
|
||||
"""
|
||||
return (move.raw_material_production_id, move.product_id.responsible_id)
|
||||
|
||||
production_documents = self._log_activity_get_documents(moves, 'move_dest_ids', 'DOWN', _keys_in_groupby)
|
||||
return {**documents, **production_documents}
|
||||
15
odoo-bringout-oca-ocb-mrp/mrp/models/stock_quant.py
Normal file
15
odoo-bringout-oca-ocb-mrp/mrp/models/stock_quant.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from odoo import models, _
|
||||
from odoo.exceptions import RedirectWarning
|
||||
|
||||
|
||||
class StockQuant(models.Model):
|
||||
_inherit = 'stock.quant'
|
||||
|
||||
def action_apply_inventory(self):
|
||||
if self.sudo().product_id.filtered("is_kits"):
|
||||
raise RedirectWarning(
|
||||
_('You should update the components quantity instead of directly updating the quantity of the kit product.'),
|
||||
self.env.ref('stock.action_view_inventory_tree').id,
|
||||
_("Return to Inventory"),
|
||||
)
|
||||
return super().action_apply_inventory()
|
||||
235
odoo-bringout-oca-ocb-mrp/mrp/models/stock_rule.py
Normal file
235
odoo-bringout-oca-ocb-mrp/mrp/models/stock_rule.py
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import defaultdict
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import api, fields, models, SUPERUSER_ID, _
|
||||
from odoo.osv import expression
|
||||
from odoo.addons.stock.models.stock_rule import ProcurementException
|
||||
from odoo.tools import float_compare, OrderedSet
|
||||
|
||||
|
||||
class StockRule(models.Model):
|
||||
_inherit = 'stock.rule'
|
||||
action = fields.Selection(selection_add=[
|
||||
('manufacture', 'Manufacture')
|
||||
], ondelete={'manufacture': 'cascade'})
|
||||
|
||||
def _get_message_dict(self):
|
||||
message_dict = super(StockRule, self)._get_message_dict()
|
||||
source, destination, operation = self._get_message_values()
|
||||
manufacture_message = _('When products are needed in <b>%s</b>, <br/> a manufacturing order is created to fulfill the need.') % (destination)
|
||||
if self.location_src_id:
|
||||
manufacture_message += _(' <br/><br/> The components will be taken from <b>%s</b>.') % (source)
|
||||
message_dict.update({
|
||||
'manufacture': manufacture_message
|
||||
})
|
||||
return message_dict
|
||||
|
||||
@api.depends('action')
|
||||
def _compute_picking_type_code_domain(self):
|
||||
remaining = self.browse()
|
||||
for rule in self:
|
||||
if rule.action == 'manufacture':
|
||||
rule.picking_type_code_domain = 'mrp_operation'
|
||||
else:
|
||||
remaining |= rule
|
||||
super(StockRule, remaining)._compute_picking_type_code_domain()
|
||||
|
||||
def _should_auto_confirm_procurement_mo(self, p):
|
||||
return (not p.orderpoint_id and p.move_raw_ids) or (p.move_dest_ids.procure_method != 'make_to_order' and not p.move_raw_ids and not p.workorder_ids)
|
||||
|
||||
@api.model
|
||||
def _run_manufacture(self, procurements):
|
||||
productions_values_by_company = defaultdict(list)
|
||||
for procurement, rule in procurements:
|
||||
if float_compare(procurement.product_qty, 0, precision_rounding=procurement.product_uom.rounding) <= 0:
|
||||
# If procurement contains negative quantity, don't create a MO that would be for a negative value.
|
||||
continue
|
||||
bom = rule._get_matching_bom(procurement.product_id, procurement.company_id, procurement.values)
|
||||
|
||||
productions_values_by_company[procurement.company_id.id].append(rule._prepare_mo_vals(*procurement, bom))
|
||||
|
||||
for company_id, productions_values in productions_values_by_company.items():
|
||||
# create the MO as SUPERUSER because the current user may not have the rights to do it (mto product launched by a sale for example)
|
||||
productions = self.env['mrp.production'].with_user(SUPERUSER_ID).sudo().with_company(company_id).create(productions_values)
|
||||
productions.filtered(self._should_auto_confirm_procurement_mo).action_confirm()
|
||||
|
||||
for production in productions:
|
||||
origin_production = production.move_dest_ids and production.move_dest_ids[0].raw_material_production_id or False
|
||||
orderpoint = production.orderpoint_id
|
||||
if orderpoint and orderpoint.create_uid.id == SUPERUSER_ID and orderpoint.trigger == 'manual':
|
||||
production.message_post(
|
||||
body=_('This production order has been created from Replenishment Report.'),
|
||||
message_type='comment',
|
||||
subtype_xmlid='mail.mt_note')
|
||||
elif orderpoint:
|
||||
production.message_post_with_view(
|
||||
'mail.message_origin_link',
|
||||
values={'self': production, 'origin': orderpoint},
|
||||
subtype_id=self.env.ref('mail.mt_note').id)
|
||||
elif origin_production:
|
||||
production.message_post_with_view(
|
||||
'mail.message_origin_link',
|
||||
values={'self': production, 'origin': origin_production},
|
||||
subtype_id=self.env.ref('mail.mt_note').id)
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def _run_pull(self, procurements):
|
||||
# Override to correctly assign the move generated from the pull
|
||||
# in its production order (pbm_sam only)
|
||||
for procurement, rule in procurements:
|
||||
warehouse_id = rule.warehouse_id
|
||||
if not warehouse_id:
|
||||
warehouse_id = rule.location_dest_id.warehouse_id
|
||||
manu_rule = rule.route_id.rule_ids.filtered(lambda r: r.action == 'manufacture' and r.warehouse_id == warehouse_id)
|
||||
if warehouse_id.manufacture_steps != 'pbm_sam' or not manu_rule:
|
||||
continue
|
||||
if rule.picking_type_id == warehouse_id.sam_type_id or (
|
||||
warehouse_id.sam_loc_id and warehouse_id.sam_loc_id.parent_path in rule.location_src_id.parent_path
|
||||
):
|
||||
if float_compare(procurement.product_qty, 0, precision_rounding=procurement.product_uom.rounding) < 0:
|
||||
procurement.values['group_id'] = procurement.values['group_id'].stock_move_ids.filtered(
|
||||
lambda m: m.state not in ['done', 'cancel']).move_orig_ids.group_id[:1]
|
||||
continue
|
||||
manu_type_id = manu_rule[0].picking_type_id
|
||||
if manu_type_id:
|
||||
name = manu_type_id.sequence_id.next_by_id()
|
||||
else:
|
||||
name = self.env['ir.sequence'].next_by_code('mrp.production') or _('New')
|
||||
# Create now the procurement group that will be assigned to the new MO
|
||||
# This ensure that the outgoing move PostProduction -> Stock is linked to its MO
|
||||
# rather than the original record (MO or SO)
|
||||
group = procurement.values.get('group_id')
|
||||
if group:
|
||||
procurement.values['group_id'] = group.copy({'name': name})
|
||||
else:
|
||||
procurement.values['group_id'] = self.env["procurement.group"].create({'name': name})
|
||||
return super()._run_pull(procurements)
|
||||
|
||||
def _get_custom_move_fields(self):
|
||||
fields = super(StockRule, self)._get_custom_move_fields()
|
||||
fields += ['bom_line_id']
|
||||
return fields
|
||||
|
||||
def _get_matching_bom(self, product_id, company_id, values):
|
||||
if values.get('bom_id', False):
|
||||
return values['bom_id']
|
||||
if values.get('orderpoint_id', False) and values['orderpoint_id'].bom_id:
|
||||
return values['orderpoint_id'].bom_id
|
||||
return self.env['mrp.bom']._bom_find(product_id, picking_type=self.picking_type_id, bom_type='normal', company_id=company_id.id)[product_id]
|
||||
|
||||
def _prepare_mo_vals(self, product_id, product_qty, product_uom, location_dest_id, name, origin, company_id, values, bom):
|
||||
date_planned = self._get_date_planned(product_id, company_id, values)
|
||||
date_deadline = values.get('date_deadline') or date_planned + relativedelta(days=product_id.produce_delay)
|
||||
mo_values = {
|
||||
'origin': origin,
|
||||
'product_id': product_id.id,
|
||||
'product_description_variants': values.get('product_description_variants'),
|
||||
'product_qty': product_uom._compute_quantity(product_qty, bom.product_uom_id) if bom else product_qty,
|
||||
'product_uom_id': bom.product_uom_id.id if bom else product_uom.id,
|
||||
'location_src_id': self.location_src_id.id or self.picking_type_id.default_location_src_id.id or location_dest_id.id,
|
||||
'location_dest_id': location_dest_id.id,
|
||||
'bom_id': bom.id,
|
||||
'date_deadline': date_deadline,
|
||||
'date_planned_start': date_planned,
|
||||
'date_planned_finished': fields.Datetime.from_string(values['date_planned']),
|
||||
'procurement_group_id': False,
|
||||
'propagate_cancel': self.propagate_cancel,
|
||||
'orderpoint_id': values.get('orderpoint_id', False) and values.get('orderpoint_id').id,
|
||||
'picking_type_id': self.picking_type_id.id or values['warehouse_id'].manu_type_id.id,
|
||||
'company_id': company_id.id,
|
||||
'move_dest_ids': values.get('move_dest_ids') and [(4, x.id) for x in values['move_dest_ids']] or False,
|
||||
'user_id': False,
|
||||
}
|
||||
# Use the procurement group created in _run_pull mrp override
|
||||
# Preserve the origin from the original stock move, if available
|
||||
if location_dest_id.warehouse_id.manufacture_steps == 'pbm_sam' and values.get('move_dest_ids') and values.get('group_id') and values['move_dest_ids'][0].origin != values['group_id'].name:
|
||||
origin = values['move_dest_ids'][0].origin
|
||||
mo_values.update({
|
||||
'name': values['group_id'].name,
|
||||
'procurement_group_id': values['group_id'].id,
|
||||
'origin': origin,
|
||||
})
|
||||
return mo_values
|
||||
|
||||
def _get_date_planned(self, product_id, company_id, values):
|
||||
format_date_planned = fields.Datetime.from_string(values['date_planned'])
|
||||
date_planned = format_date_planned - relativedelta(days=product_id.produce_delay)
|
||||
if date_planned == format_date_planned:
|
||||
date_planned = date_planned - relativedelta(hours=1)
|
||||
return date_planned
|
||||
|
||||
def _get_lead_days(self, product, **values):
|
||||
"""Add the product and company manufacture delay to the cumulative delay
|
||||
and cumulative description.
|
||||
"""
|
||||
delay, delay_description = super()._get_lead_days(product, **values)
|
||||
bypass_delay_description = self.env.context.get('bypass_delay_description')
|
||||
manufacture_rule = self.filtered(lambda r: r.action == 'manufacture')
|
||||
if not manufacture_rule:
|
||||
return delay, delay_description
|
||||
manufacture_rule.ensure_one()
|
||||
manufacture_delay = product.produce_delay
|
||||
delay += manufacture_delay
|
||||
if not bypass_delay_description:
|
||||
delay_description.append((_('Manufacturing Lead Time'), _('+ %d day(s)', manufacture_delay)))
|
||||
security_delay = manufacture_rule.picking_type_id.company_id.manufacturing_lead
|
||||
delay += security_delay
|
||||
if not bypass_delay_description:
|
||||
delay_description.append((_('Manufacture Security Lead Time'), _('+ %d day(s)', security_delay)))
|
||||
days_to_order = values.get('days_to_order', product.product_tmpl_id.days_to_prepare_mo)
|
||||
if not bypass_delay_description:
|
||||
delay_description.append((_('Days to Supply Components'), _('+ %d day(s)', days_to_order)))
|
||||
return delay + days_to_order, delay_description
|
||||
|
||||
def _push_prepare_move_copy_values(self, move_to_copy, new_date):
|
||||
new_move_vals = super(StockRule, self)._push_prepare_move_copy_values(move_to_copy, new_date)
|
||||
new_move_vals['production_id'] = False
|
||||
return new_move_vals
|
||||
|
||||
|
||||
class ProcurementGroup(models.Model):
|
||||
_inherit = 'procurement.group'
|
||||
|
||||
mrp_production_ids = fields.One2many('mrp.production', 'procurement_group_id')
|
||||
|
||||
@api.model
|
||||
def run(self, procurements, raise_user_error=True):
|
||||
""" If 'run' is called on a kit, this override is made in order to call
|
||||
the original 'run' method with the values of the components of that kit.
|
||||
"""
|
||||
procurements_without_kit = []
|
||||
product_by_company = defaultdict(OrderedSet)
|
||||
for procurement in procurements:
|
||||
product_by_company[procurement.company_id].add(procurement.product_id.id)
|
||||
kits_by_company = {
|
||||
company: self.env['mrp.bom']._bom_find(self.env['product.product'].browse(product_ids), company_id=company.id, bom_type='phantom')
|
||||
for company, product_ids in product_by_company.items()
|
||||
}
|
||||
for procurement in procurements:
|
||||
bom_kit = kits_by_company[procurement.company_id].get(procurement.product_id)
|
||||
if bom_kit:
|
||||
order_qty = procurement.product_uom._compute_quantity(procurement.product_qty, bom_kit.product_uom_id, round=False)
|
||||
qty_to_produce = (order_qty / bom_kit.product_qty)
|
||||
boms, bom_sub_lines = bom_kit.explode(procurement.product_id, qty_to_produce)
|
||||
for bom_line, bom_line_data in bom_sub_lines:
|
||||
bom_line_uom = bom_line.product_uom_id
|
||||
quant_uom = bom_line.product_id.uom_id
|
||||
# recreate dict of values since each child has its own bom_line_id
|
||||
values = dict(procurement.values, bom_line_id=bom_line.id)
|
||||
component_qty, procurement_uom = bom_line_uom._adjust_uom_quantities(bom_line_data['qty'], quant_uom)
|
||||
procurements_without_kit.append(self.env['procurement.group'].Procurement(
|
||||
bom_line.product_id, component_qty, procurement_uom,
|
||||
procurement.location_id, procurement.name,
|
||||
procurement.origin, procurement.company_id, values))
|
||||
else:
|
||||
procurements_without_kit.append(procurement)
|
||||
return super(ProcurementGroup, self).run(procurements_without_kit, raise_user_error=raise_user_error)
|
||||
|
||||
def _get_moves_to_assign_domain(self, company_id):
|
||||
domain = super(ProcurementGroup, self)._get_moves_to_assign_domain(company_id)
|
||||
domain = expression.AND([domain, [('production_id', '=', False)]])
|
||||
return domain
|
||||
52
odoo-bringout-oca-ocb-mrp/mrp/models/stock_scrap.py
Normal file
52
odoo-bringout-oca-ocb-mrp/mrp/models/stock_scrap.py
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
|
||||
class StockScrap(models.Model):
|
||||
_inherit = 'stock.scrap'
|
||||
|
||||
production_id = fields.Many2one(
|
||||
'mrp.production', 'Manufacturing Order',
|
||||
states={'done': [('readonly', True)]}, check_company=True)
|
||||
workorder_id = fields.Many2one(
|
||||
'mrp.workorder', 'Work Order',
|
||||
states={'done': [('readonly', True)]},
|
||||
check_company=True) # Not to restrict or prefer quants, but informative
|
||||
|
||||
@api.onchange('workorder_id')
|
||||
def _onchange_workorder_id(self):
|
||||
if self.workorder_id:
|
||||
self.location_id = self.workorder_id.production_id.location_src_id.id
|
||||
|
||||
@api.onchange('production_id')
|
||||
def _onchange_production_id(self):
|
||||
if self.production_id:
|
||||
self.location_id = self.production_id.move_raw_ids.filtered(lambda x: x.state not in ('done', 'cancel')) and self.production_id.location_src_id.id or self.production_id.location_dest_id.id
|
||||
|
||||
def _prepare_move_values(self):
|
||||
vals = super(StockScrap, self)._prepare_move_values()
|
||||
if self.production_id:
|
||||
vals['origin'] = vals['origin'] or self.production_id.name
|
||||
if self.product_id in self.production_id.move_finished_ids.mapped('product_id'):
|
||||
vals.update({'production_id': self.production_id.id})
|
||||
else:
|
||||
vals.update({'raw_material_production_id': self.production_id.id})
|
||||
return vals
|
||||
|
||||
@api.onchange('lot_id')
|
||||
def _onchange_serial_number(self):
|
||||
if self.product_id.tracking == 'serial' and self.lot_id:
|
||||
if self.production_id:
|
||||
message, recommended_location = self.env['stock.quant']._check_serial_number(self.product_id,
|
||||
self.lot_id,
|
||||
self.company_id,
|
||||
self.location_id,
|
||||
self.production_id.location_dest_id)
|
||||
if message:
|
||||
if recommended_location:
|
||||
self.location_id = recommended_location
|
||||
return {'warning': {'title': _('Warning'), 'message': message}}
|
||||
else:
|
||||
return super()._onchange_serial_number()
|
||||
34
odoo-bringout-oca-ocb-mrp/mrp/models/stock_traceability.py
Normal file
34
odoo-bringout-oca-ocb-mrp/mrp/models/stock_traceability.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
from odoo import models, api
|
||||
|
||||
class MrpStockReport(models.TransientModel):
|
||||
_inherit = 'stock.traceability.report'
|
||||
|
||||
@api.model
|
||||
def _get_reference(self, move_line):
|
||||
res_model, res_id, ref = super(MrpStockReport, self)._get_reference(move_line)
|
||||
if move_line.move_id.production_id and not move_line.move_id.scrapped:
|
||||
res_model = 'mrp.production'
|
||||
res_id = move_line.move_id.production_id.id
|
||||
ref = move_line.move_id.production_id.name
|
||||
if move_line.move_id.raw_material_production_id and not move_line.move_id.scrapped:
|
||||
res_model = 'mrp.production'
|
||||
res_id = move_line.move_id.raw_material_production_id.id
|
||||
ref = move_line.move_id.raw_material_production_id.name
|
||||
if move_line.move_id.unbuild_id:
|
||||
res_model = 'mrp.unbuild'
|
||||
res_id = move_line.move_id.unbuild_id.id
|
||||
ref = move_line.move_id.unbuild_id.name
|
||||
if move_line.move_id.consume_unbuild_id:
|
||||
res_model = 'mrp.unbuild'
|
||||
res_id = move_line.move_id.consume_unbuild_id.id
|
||||
ref = move_line.move_id.consume_unbuild_id.name
|
||||
return res_model, res_id, ref
|
||||
|
||||
@api.model
|
||||
def _get_linked_move_lines(self, move_line):
|
||||
move_lines, is_used = super(MrpStockReport, self)._get_linked_move_lines(move_line)
|
||||
if not move_lines:
|
||||
move_lines = (move_line.move_id.consume_unbuild_id and move_line.produce_line_ids) or (move_line.move_id.production_id and move_line.consume_line_ids)
|
||||
if not is_used:
|
||||
is_used = (move_line.move_id.unbuild_id and move_line.consume_line_ids) or (move_line.move_id.raw_material_production_id and move_line.produce_line_ids)
|
||||
return move_lines, is_used
|
||||
324
odoo-bringout-oca-ocb-mrp/mrp/models/stock_warehouse.py
Normal file
324
odoo-bringout-oca-ocb-mrp/mrp/models/stock_warehouse.py
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError, UserError
|
||||
from odoo.tools import split_every
|
||||
|
||||
|
||||
class StockWarehouse(models.Model):
|
||||
_inherit = 'stock.warehouse'
|
||||
|
||||
manufacture_to_resupply = fields.Boolean(
|
||||
'Manufacture to Resupply', default=True,
|
||||
help="When products are manufactured, they can be manufactured in this warehouse.")
|
||||
manufacture_pull_id = fields.Many2one(
|
||||
'stock.rule', 'Manufacture Rule')
|
||||
manufacture_mto_pull_id = fields.Many2one(
|
||||
'stock.rule', 'Manufacture MTO Rule')
|
||||
pbm_mto_pull_id = fields.Many2one(
|
||||
'stock.rule', 'Picking Before Manufacturing MTO Rule')
|
||||
sam_rule_id = fields.Many2one(
|
||||
'stock.rule', 'Stock After Manufacturing Rule')
|
||||
manu_type_id = fields.Many2one(
|
||||
'stock.picking.type', 'Manufacturing Operation Type',
|
||||
domain="[('code', '=', 'mrp_operation'), ('company_id', '=', company_id)]", check_company=True)
|
||||
|
||||
pbm_type_id = fields.Many2one('stock.picking.type', 'Picking Before Manufacturing Operation Type', check_company=True)
|
||||
sam_type_id = fields.Many2one('stock.picking.type', 'Stock After Manufacturing Operation Type', check_company=True)
|
||||
|
||||
manufacture_steps = fields.Selection([
|
||||
('mrp_one_step', 'Manufacture (1 step)'),
|
||||
('pbm', 'Pick components and then manufacture (2 steps)'),
|
||||
('pbm_sam', 'Pick components, manufacture and then store products (3 steps)')],
|
||||
'Manufacture', default='mrp_one_step', required=True,
|
||||
help="Produce : Move the components to the production location\
|
||||
directly and start the manufacturing process.\nPick / Produce : Unload\
|
||||
the components from the Stock to Input location first, and then\
|
||||
transfer it to the Production location.")
|
||||
|
||||
pbm_route_id = fields.Many2one('stock.route', 'Picking Before Manufacturing Route', ondelete='restrict')
|
||||
|
||||
pbm_loc_id = fields.Many2one('stock.location', 'Picking before Manufacturing Location', check_company=True)
|
||||
sam_loc_id = fields.Many2one('stock.location', 'Stock after Manufacturing Location', check_company=True)
|
||||
|
||||
def get_rules_dict(self):
|
||||
result = super(StockWarehouse, self).get_rules_dict()
|
||||
production_location_id = self._get_production_location()
|
||||
for warehouse in self:
|
||||
result[warehouse.id].update({
|
||||
'mrp_one_step': [],
|
||||
'pbm': [
|
||||
self.Routing(warehouse.lot_stock_id, warehouse.pbm_loc_id, warehouse.pbm_type_id, 'pull'),
|
||||
self.Routing(warehouse.pbm_loc_id, production_location_id, warehouse.manu_type_id, 'pull'),
|
||||
],
|
||||
'pbm_sam': [
|
||||
self.Routing(warehouse.lot_stock_id, warehouse.pbm_loc_id, warehouse.pbm_type_id, 'pull'),
|
||||
self.Routing(warehouse.pbm_loc_id, production_location_id, warehouse.manu_type_id, 'pull'),
|
||||
self.Routing(warehouse.sam_loc_id, warehouse.lot_stock_id, warehouse.sam_type_id, 'push'),
|
||||
],
|
||||
})
|
||||
result[warehouse.id].update(warehouse._get_receive_rules_dict())
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def _get_production_location(self):
|
||||
location = self.env['stock.location'].search([('usage', '=', 'production'), ('company_id', '=', self.company_id.id)], limit=1)
|
||||
if not location:
|
||||
raise UserError(_('Can\'t find any production location.'))
|
||||
return location
|
||||
|
||||
def _get_routes_values(self):
|
||||
routes = super(StockWarehouse, self)._get_routes_values()
|
||||
routes.update({
|
||||
'pbm_route_id': {
|
||||
'routing_key': self.manufacture_steps,
|
||||
'depends': ['manufacture_steps', 'manufacture_to_resupply'],
|
||||
'route_update_values': {
|
||||
'name': self._format_routename(route_type=self.manufacture_steps),
|
||||
'active': self.manufacture_steps != 'mrp_one_step',
|
||||
},
|
||||
'route_create_values': {
|
||||
'product_categ_selectable': True,
|
||||
'warehouse_selectable': True,
|
||||
'product_selectable': False,
|
||||
'company_id': self.company_id.id,
|
||||
'sequence': 10,
|
||||
},
|
||||
'rules_values': {
|
||||
'active': True,
|
||||
}
|
||||
}
|
||||
})
|
||||
routes.update(self._get_receive_routes_values('manufacture_to_resupply'))
|
||||
return routes
|
||||
|
||||
def _get_route_name(self, route_type):
|
||||
names = {
|
||||
'mrp_one_step': _('Manufacture (1 step)'),
|
||||
'pbm': _('Pick components and then manufacture'),
|
||||
'pbm_sam': _('Pick components, manufacture and then store products (3 steps)'),
|
||||
}
|
||||
if route_type in names:
|
||||
return names[route_type]
|
||||
else:
|
||||
return super(StockWarehouse, self)._get_route_name(route_type)
|
||||
|
||||
def _get_global_route_rules_values(self):
|
||||
rules = super(StockWarehouse, self)._get_global_route_rules_values()
|
||||
location_src = self.manufacture_steps == 'mrp_one_step' and self.lot_stock_id or self.pbm_loc_id
|
||||
production_location = self._get_production_location()
|
||||
location_dest_id = self.manufacture_steps == 'pbm_sam' and self.sam_loc_id or self.lot_stock_id
|
||||
rules.update({
|
||||
'manufacture_pull_id': {
|
||||
'depends': ['manufacture_steps', 'manufacture_to_resupply'],
|
||||
'create_values': {
|
||||
'action': 'manufacture',
|
||||
'procure_method': 'make_to_order',
|
||||
'company_id': self.company_id.id,
|
||||
'picking_type_id': self.manu_type_id.id,
|
||||
'route_id': self._find_or_create_global_route('mrp.route_warehouse0_manufacture', _('Manufacture')).id
|
||||
},
|
||||
'update_values': {
|
||||
'active': self.manufacture_to_resupply,
|
||||
'name': self._format_rulename(location_dest_id, False, 'Production'),
|
||||
'location_dest_id': location_dest_id.id,
|
||||
'propagate_cancel': self.manufacture_steps == 'pbm_sam'
|
||||
},
|
||||
},
|
||||
'manufacture_mto_pull_id': {
|
||||
'depends': ['manufacture_steps', 'manufacture_to_resupply'],
|
||||
'create_values': {
|
||||
'procure_method': 'mts_else_mto',
|
||||
'company_id': self.company_id.id,
|
||||
'action': 'pull',
|
||||
'auto': 'manual',
|
||||
'route_id': self._find_or_create_global_route('stock.route_warehouse0_mto', _('Make To Order')).id,
|
||||
'location_dest_id': production_location.id,
|
||||
'location_src_id': location_src.id,
|
||||
'picking_type_id': self.manu_type_id.id
|
||||
},
|
||||
'update_values': {
|
||||
'name': self._format_rulename(location_src, production_location, 'MTO'),
|
||||
'active': self.manufacture_to_resupply,
|
||||
},
|
||||
},
|
||||
'pbm_mto_pull_id': {
|
||||
'depends': ['manufacture_steps', 'manufacture_to_resupply'],
|
||||
'create_values': {
|
||||
'procure_method': 'make_to_order',
|
||||
'company_id': self.company_id.id,
|
||||
'action': 'pull',
|
||||
'auto': 'manual',
|
||||
'route_id': self._find_or_create_global_route('stock.route_warehouse0_mto', _('Make To Order')).id,
|
||||
'name': self._format_rulename(self.lot_stock_id, self.pbm_loc_id, 'MTO'),
|
||||
'location_dest_id': self.pbm_loc_id.id,
|
||||
'location_src_id': self.lot_stock_id.id,
|
||||
'picking_type_id': self.pbm_type_id.id
|
||||
},
|
||||
'update_values': {
|
||||
'active': self.manufacture_steps != 'mrp_one_step' and self.manufacture_to_resupply,
|
||||
}
|
||||
},
|
||||
# The purpose to move sam rule in the manufacture route instead of
|
||||
# pbm_route_id is to avoid conflict with receipt in multiple
|
||||
# step. For example if the product is manufacture and receipt in two
|
||||
# step it would conflict in WH/Stock since product could come from
|
||||
# WH/post-prod or WH/input. We do not have this conflict with
|
||||
# manufacture route since it is set on the product.
|
||||
'sam_rule_id': {
|
||||
'depends': ['manufacture_steps', 'manufacture_to_resupply'],
|
||||
'create_values': {
|
||||
'procure_method': 'make_to_order',
|
||||
'company_id': self.company_id.id,
|
||||
'action': 'pull',
|
||||
'auto': 'manual',
|
||||
'route_id': self._find_or_create_global_route('mrp.route_warehouse0_manufacture', _('Manufacture')).id,
|
||||
'name': self._format_rulename(self.sam_loc_id, self.lot_stock_id, False),
|
||||
'location_dest_id': self.lot_stock_id.id,
|
||||
'location_src_id': self.sam_loc_id.id,
|
||||
'picking_type_id': self.sam_type_id.id
|
||||
},
|
||||
'update_values': {
|
||||
'active': self.manufacture_steps == 'pbm_sam' and self.manufacture_to_resupply,
|
||||
}
|
||||
}
|
||||
|
||||
})
|
||||
return rules
|
||||
|
||||
def _get_locations_values(self, vals, code=False):
|
||||
values = super(StockWarehouse, self)._get_locations_values(vals, code=code)
|
||||
def_values = self.default_get(['company_id', 'manufacture_steps'])
|
||||
manufacture_steps = vals.get('manufacture_steps', def_values['manufacture_steps'])
|
||||
code = vals.get('code') or code or ''
|
||||
code = code.replace(' ', '').upper()
|
||||
company_id = vals.get('company_id', def_values['company_id'])
|
||||
values.update({
|
||||
'pbm_loc_id': {
|
||||
'name': _('Pre-Production'),
|
||||
'active': manufacture_steps in ('pbm', 'pbm_sam'),
|
||||
'usage': 'internal',
|
||||
'barcode': self._valid_barcode(code + '-PREPRODUCTION', company_id)
|
||||
},
|
||||
'sam_loc_id': {
|
||||
'name': _('Post-Production'),
|
||||
'active': manufacture_steps == 'pbm_sam',
|
||||
'usage': 'internal',
|
||||
'barcode': self._valid_barcode(code + '-POSTPRODUCTION', company_id)
|
||||
},
|
||||
})
|
||||
return values
|
||||
|
||||
def _get_sequence_values(self, name=False, code=False):
|
||||
values = super(StockWarehouse, self)._get_sequence_values(name=name, code=code)
|
||||
values.update({
|
||||
'pbm_type_id': {'name': self.name + ' ' + _('Sequence picking before manufacturing'), 'prefix': self.code + '/' + (self.pbm_type_id.sequence_code or 'PC') + '/', 'padding': 5, 'company_id': self.company_id.id},
|
||||
'sam_type_id': {'name': self.name + ' ' + _('Sequence stock after manufacturing'), 'prefix': self.code + '/' + (self.sam_type_id.sequence_code or 'SFP') + '/', 'padding': 5, 'company_id': self.company_id.id},
|
||||
'manu_type_id': {'name': self.name + ' ' + _('Sequence production'), 'prefix': self.code + '/' + (self.manu_type_id.sequence_code or 'MO') + '/', 'padding': 5, 'company_id': self.company_id.id},
|
||||
})
|
||||
return values
|
||||
|
||||
def _get_picking_type_create_values(self, max_sequence):
|
||||
data, next_sequence = super(StockWarehouse, self)._get_picking_type_create_values(max_sequence)
|
||||
data.update({
|
||||
'pbm_type_id': {
|
||||
'name': _('Pick Components'),
|
||||
'code': 'internal',
|
||||
'use_create_lots': True,
|
||||
'use_existing_lots': True,
|
||||
'default_location_src_id': self.lot_stock_id.id,
|
||||
'default_location_dest_id': self.pbm_loc_id.id,
|
||||
'sequence': next_sequence + 1,
|
||||
'sequence_code': 'PC',
|
||||
'company_id': self.company_id.id,
|
||||
},
|
||||
'sam_type_id': {
|
||||
'name': _('Store Finished Product'),
|
||||
'code': 'internal',
|
||||
'use_create_lots': True,
|
||||
'use_existing_lots': True,
|
||||
'default_location_src_id': self.sam_loc_id.id,
|
||||
'default_location_dest_id': self.lot_stock_id.id,
|
||||
'sequence': next_sequence + 3,
|
||||
'sequence_code': 'SFP',
|
||||
'company_id': self.company_id.id,
|
||||
},
|
||||
'manu_type_id': {
|
||||
'name': _('Manufacturing'),
|
||||
'code': 'mrp_operation',
|
||||
'use_create_lots': True,
|
||||
'use_existing_lots': True,
|
||||
'sequence': next_sequence + 2,
|
||||
'sequence_code': 'MO',
|
||||
'company_id': self.company_id.id,
|
||||
},
|
||||
})
|
||||
return data, max_sequence + 4
|
||||
|
||||
def _get_picking_type_update_values(self):
|
||||
data = super(StockWarehouse, self)._get_picking_type_update_values()
|
||||
data.update({
|
||||
'pbm_type_id': {
|
||||
'active': self.manufacture_to_resupply and self.manufacture_steps in ('pbm', 'pbm_sam') and self.active,
|
||||
'barcode': self.code.replace(" ", "").upper() + "-PC",
|
||||
},
|
||||
'sam_type_id': {
|
||||
'active': self.manufacture_to_resupply and self.manufacture_steps == 'pbm_sam' and self.active,
|
||||
'barcode': self.code.replace(" ", "").upper() + "-SFP",
|
||||
},
|
||||
'manu_type_id': {
|
||||
'active': self.manufacture_to_resupply and self.active,
|
||||
'default_location_src_id': self.manufacture_steps in ('pbm', 'pbm_sam') and self.pbm_loc_id.id or self.lot_stock_id.id,
|
||||
'default_location_dest_id': self.manufacture_steps == 'pbm_sam' and self.sam_loc_id.id or self.lot_stock_id.id,
|
||||
},
|
||||
})
|
||||
return data
|
||||
|
||||
def _create_missing_locations(self, vals):
|
||||
super()._create_missing_locations(vals)
|
||||
for company_id in self.company_id:
|
||||
location = self.env['stock.location'].search([('usage', '=', 'production'), ('company_id', '=', company_id.id)], limit=1)
|
||||
if not location:
|
||||
company_id._create_production_location()
|
||||
|
||||
def write(self, vals):
|
||||
if any(field in vals for field in ('manufacture_steps', 'manufacture_to_resupply')):
|
||||
for warehouse in self:
|
||||
warehouse._update_location_manufacture(vals.get('manufacture_steps', warehouse.manufacture_steps))
|
||||
return super(StockWarehouse, self).write(vals)
|
||||
|
||||
def _get_all_routes(self):
|
||||
routes = super(StockWarehouse, self)._get_all_routes()
|
||||
routes |= self.filtered(lambda self: self.manufacture_to_resupply and self.manufacture_pull_id and self.manufacture_pull_id.route_id).mapped('manufacture_pull_id').mapped('route_id')
|
||||
return routes
|
||||
|
||||
def _update_location_manufacture(self, new_manufacture_step):
|
||||
self.mapped('pbm_loc_id').write({'active': new_manufacture_step != 'mrp_one_step'})
|
||||
self.mapped('sam_loc_id').write({'active': new_manufacture_step == 'pbm_sam'})
|
||||
|
||||
def _update_name_and_code(self, name=False, code=False):
|
||||
res = super(StockWarehouse, self)._update_name_and_code(name, code)
|
||||
# change the manufacture stock rule name
|
||||
for warehouse in self:
|
||||
if warehouse.manufacture_pull_id and name:
|
||||
warehouse.manufacture_pull_id.write({'name': warehouse.manufacture_pull_id.name.replace(warehouse.name, name, 1)})
|
||||
return res
|
||||
|
||||
class Orderpoint(models.Model):
|
||||
_inherit = "stock.warehouse.orderpoint"
|
||||
|
||||
@api.constrains('product_id')
|
||||
def check_product_is_not_kit(self):
|
||||
if self.env['mrp.bom'].search(['|', ('product_id', 'in', self.product_id.ids),
|
||||
'&', ('product_id', '=', False), ('product_tmpl_id', 'in', self.product_id.product_tmpl_id.ids),
|
||||
('type', '=', 'phantom')], count=True):
|
||||
raise ValidationError(_("A product with a kit-type bill of materials can not have a reordering rule."))
|
||||
|
||||
def _get_orderpoint_products(self):
|
||||
non_kit_ids = []
|
||||
for products in split_every(2000, super()._get_orderpoint_products().ids, self.env['product.product'].browse):
|
||||
kit_ids = set(k.id for k in self.env['mrp.bom']._bom_find(products, bom_type='phantom').keys())
|
||||
non_kit_ids.extend(id_ for id_ in products.ids if id_ not in kit_ids)
|
||||
products.invalidate_recordset()
|
||||
return self.env['product.product'].browse(non_kit_ids)
|
||||
Loading…
Add table
Add a link
Reference in a new issue