diff --git a/addons/mrp/data/mrp_demo.xml b/addons/mrp/data/mrp_demo.xml index e62afc6aceb..90872b18b38 100644 --- a/addons/mrp/data/mrp_demo.xml +++ b/addons/mrp/data/mrp_demo.xml @@ -78,13 +78,6 @@ 3 - - - - - @@ -295,12 +288,6 @@ - - - - @@ -402,14 +389,6 @@ - - - - - - @@ -651,12 +630,6 @@ - - - - @@ -704,10 +677,6 @@ - - - - - - - - diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 629bb7a55b2..7061d7aec07 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -28,40 +28,6 @@ class MrpProduction(models.Model): _inherit = ['mail.thread', 'mail.activity.mixin'] _order = 'priority desc, date_planned_start asc,id' - @api.model - def _get_default_picking_type(self): - company_id = self.env.context.get('default_company_id', self.env.company.id) - return self.env['stock.picking.type'].search([ - ('code', '=', 'mrp_operation'), - ('warehouse_id.company_id', '=', company_id), - ], limit=1).id - - @api.model - def _get_default_location_src_id(self): - location = False - company_id = self.env.context.get('default_company_id', self.env.company.id) - if self.env.context.get('default_picking_type_id'): - location = self.env['stock.picking.type'].browse(self.env.context['default_picking_type_id']).default_location_src_id - if not location: - location = self.env['stock.warehouse'].search([('company_id', '=', company_id)], limit=1).lot_stock_id - return location and location.id or False - - @api.model - def _get_default_location_dest_id(self): - location = False - company_id = self.env.context.get('default_company_id', self.env.company.id) - if self._context.get('default_picking_type_id'): - location = self.env['stock.picking.type'].browse(self.env.context['default_picking_type_id']).default_location_dest_id - if not location: - location = self.env['stock.warehouse'].search([('company_id', '=', company_id)], limit=1).lot_stock_id - return location and location.id or False - - @api.model - def _get_default_date_planned_finished(self): - if self.env.context.get('default_date_planned_start'): - return fields.Datetime.to_datetime(self.env.context.get('default_date_planned_start')) + datetime.timedelta(hours=1) - return datetime.datetime.now() + datetime.timedelta(hours=1) - @api.model def _get_default_date_planned_start(self): if self.env.context.get('default_date_deadline'): @@ -72,8 +38,7 @@ class MrpProduction(models.Model): def _get_default_is_locked(self): return not self.user_has_groups('mrp.group_unlocked_by_default') - name = fields.Char( - 'Reference', copy=False, readonly=True, default=lambda x: _('New')) + name = fields.Char('Reference', copy=False, readonly=True) priority = fields.Selection( PROCUREMENT_PRIORITIES, string='Priority', default='0', help="Components will be reserved first for the MO with the highest priorities.") @@ -92,19 +57,19 @@ class MrpProduction(models.Model): ('company_id', '=', company_id) ] """, + compute='_compute_product_id', store=True, copy=True, readonly=True, required=True, check_company=True, states={'draft': [('readonly', False)]}) product_tracking = fields.Selection(related='product_id.tracking') product_tmpl_id = fields.Many2one('product.template', 'Product Template', related='product_id.product_tmpl_id') product_qty = fields.Float( - 'Quantity To Produce', - default=1.0, digits='Product Unit of Measure', - readonly=True, required=True, tracking=True, - states={'draft': [('readonly', False)]}) + 'Quantity To Produce', digits='Product Unit of Measure', + readonly=False, required=True, tracking=True, default=1.0, + compute='_compute_product_qty', store=True, copy=True) product_uom_id = fields.Many2one( 'uom.uom', 'Product Unit of Measure', - readonly=True, required=True, - states={'draft': [('readonly', False)]}, domain="[('category_id', '=', product_uom_category_id)]") + readonly=False, required=True, compute='_compute_uom_id', store=True, copy=True, + domain="[('category_id', '=', product_uom_category_id)]") lot_producing_id = fields.Many2one( 'stock.lot', string='Lot/Serial Number', copy=False, domain="[('product_id', '=', product_id), ('company_id', '=', company_id)]", check_company=True) @@ -112,23 +77,22 @@ class MrpProduction(models.Model): product_uom_category_id = fields.Many2one(related='product_id.uom_id.category_id') product_uom_qty = fields.Float(string='Total Quantity', compute='_compute_product_uom_qty', store=True) picking_type_id = fields.Many2one( - 'stock.picking.type', 'Operation Type', + 'stock.picking.type', 'Operation Type', copy=True, readonly=False, + compute='_compute_picking_type_id', store=True, precompute=True, domain="[('code', '=', 'mrp_operation'), ('company_id', '=', company_id)]", - default=_get_default_picking_type, required=True, check_company=True, index=True) + required=True, check_company=True, index=True) use_create_components_lots = fields.Boolean(related='picking_type_id.use_create_components_lots') location_src_id = fields.Many2one( 'stock.location', 'Components Location', - default=_get_default_location_src_id, - readonly=True, required=True, + compute='_compute_locations', store=True, check_company=True, + readonly=False, required=True, precompute=True, domain="[('usage','=','internal'), '|', ('company_id', '=', False), ('company_id', '=', company_id)]", - states={'draft': [('readonly', False)]}, check_company=True, help="Location where the system will look for components.") location_dest_id = fields.Many2one( 'stock.location', 'Finished Products Location', - default=_get_default_location_dest_id, - readonly=True, required=True, + compute='_compute_locations', store=True, check_company=True, + readonly=False, required=True, precompute=True, domain="[('usage','=','internal'), '|', ('company_id', '=', False), ('company_id', '=', company_id)]", - states={'draft': [('readonly', False)]}, check_company=True, help="Location where the system will stock the finished products.") date_planned_start = fields.Datetime( 'Scheduled Date', copy=False, default=_get_default_date_planned_start, @@ -136,11 +100,11 @@ class MrpProduction(models.Model): index=True, required=True) date_planned_finished = fields.Datetime( 'Scheduled End Date', - default=_get_default_date_planned_finished, + compute='_compute_date_planned_finished', store=True, help="Date at which you plan to finish the production.", copy=False) date_deadline = fields.Datetime( - 'Deadline', copy=False, store=True, readonly=True, compute='_compute_date_deadline', inverse='_set_date_deadline', + 'Deadline', copy=False, store=True, readonly=True, compute='_compute_date_deadline', help="Informative date allowing to define when the manufacturing order should be processed at the latest to fulfill delivery on time.") date_start = fields.Datetime('Start Date', copy=False, readonly=True, help="Date of the WO") date_finished = fields.Datetime('End Date', copy=False, readonly=True, help="Date when the MO has been close") @@ -149,8 +113,7 @@ class MrpProduction(models.Model): production_real_duration = fields.Float("Real Duration", help="Total real duration (in minutes)", compute='_compute_production_real_duration') bom_id = fields.Many2one( - 'mrp.bom', 'Bill of Material', - readonly=True, states={'draft': [('readonly', False)]}, + 'mrp.bom', 'Bill of Material', readonly=False, domain="""[ '&', '|', @@ -163,7 +126,7 @@ class MrpProduction(models.Model): ('product_tmpl_id.product_variant_ids','=',product_id), ('product_id','=',False), ('type', '=', 'normal')]""", - check_company=True, + check_company=True, compute='_compute_bom_id', store=True, help="Bill of Materials allow you to define the list of required components to make a finished product.") state = fields.Selection([ @@ -194,18 +157,20 @@ class MrpProduction(models.Model): move_raw_ids = fields.One2many( 'stock.move', 'raw_material_production_id', 'Components', + compute='_compute_move_raw_ids', store=True, readonly=False, copy=False, states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, domain=[('scrapped', '=', False)]) move_finished_ids = fields.One2many( - 'stock.move', 'production_id', 'Finished Products', - copy=False, states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, + 'stock.move', 'production_id', 'Finished Products', readonly=False, + compute='_compute_move_finished_ids', store=True, copy=False, domain=[('scrapped', '=', False)]) move_byproduct_ids = fields.One2many('stock.move', compute='_compute_move_byproduct_ids', inverse='_set_move_byproduct_ids') finished_move_line_ids = fields.One2many( 'stock.move.line', compute='_compute_lines', inverse='_inverse_lines', string="Finished Product" ) workorder_ids = fields.One2many( - 'mrp.workorder', 'production_id', 'Work Orders', copy=True) + 'mrp.workorder', 'production_id', 'Work Orders', copy=True, + compute='_compute_workorder_ids', store=True, readonly=False) move_dest_ids = fields.One2many('stock.move', 'created_production_id', string="Stock Movements of Produced Goods") @@ -273,6 +238,11 @@ class MrpProduction(models.Model): compute='_compute_show_allocation', help='Technical Field used to decide whether the button "Allocation" should be displayed.') + _sql_constraints = [ + ('name_uniq', 'unique(name, company_id)', 'Reference must be unique per Company!'), + ('qty_positive', 'check (product_qty > 0)', 'The quantity to produce must be positive!'), + ] + @api.depends('procurement_group_id.stock_move_ids.created_production_id.procurement_group_id.mrp_production_ids') def _compute_mrp_production_child_count(self): for production in self: @@ -288,6 +258,40 @@ class MrpProduction(models.Model): for production in self: production.mrp_production_backorder_count = len(production.procurement_group_id.mrp_production_ids) + @api.depends('company_id') + def _compute_picking_type_id(self): + domain = [ + ('code', '=', 'mrp_operation'), + ('warehouse_id.company_id', 'in', self.company_id.ids), + ] + picking_types = self.env['stock.picking.type'].search_read(domain, ['company_id'], load=False, limit=1) + picking_type_by_company = {pt['company_id']: pt['id'] for pt in picking_types} + for mo in self: + if mo.picking_type_id and mo.picking_type_id.company_id == mo.company_id: + continue + mo.picking_type_id = picking_type_by_company.get(mo.company_id.id, False) + + @api.depends('bom_id', 'product_id') + def _compute_uom_id(self): + for production in self: + if production.state != 'draft': + continue + if production.bom_id and production._origin.bom_id != production.bom_id: + production.product_uom_id = production.bom_id.product_uom_id + elif production.product_id: + production.product_uom_id = production.product_id.uom_id + else: + production.product_uom_id = False + + @api.depends('picking_type_id') + def _compute_locations(self): + for production in self: + if not production.picking_type_id.default_location_src_id or not production.picking_type_id.default_location_dest_id: + company_id = production.company_id.id if (production.company_id and production.company_id in self.env.companies) else self.env.company.id + fallback_loc = self.env['stock.warehouse'].search([('company_id', '=', company_id)], limit=1).lot_stock_id + production.location_src_id = production.picking_type_id.default_location_src_id.id or fallback_loc.id + production.location_dest_id = production.picking_type_id.default_location_dest_id.id or fallback_loc.id + @api.depends('state', 'reservation_state', 'date_planned_start', 'move_raw_ids', 'move_raw_ids.forecast_availability', 'move_raw_ids.forecast_expected_date') def _compute_components_availability(self): productions = self.filtered(lambda mo: mo.state not in ('cancel', 'done', 'draft')) @@ -312,15 +316,45 @@ class MrpProduction(models.Model): if production.date_planned_start: production.components_availability_state = 'late' if forecast_date > production.date_planned_start else 'expected' + @api.depends('bom_id') + def _compute_product_id(self): + for production in self: + bom = production.bom_id + if bom and ( + not production.product_id or bom.product_tmpl_id != production.product_tmpl_id + or bom.product_id and bom.product_id != production.product_id + ): + production.product_id = bom.product_id or bom.product_tmpl_id.product_variant_id + + @api.depends('product_id', 'picking_type_id') + def _compute_bom_id(self): + mo_by_picking_type_and_company_id = defaultdict(lambda: self.env['mrp.production']) + for mo in self: + if not mo.product_id and not mo.bom_id: + mo.bom_id = False + continue + mo_by_picking_type_and_company_id[(mo.picking_type_id, mo.company_id.id)] |= mo + + for (picking_type, company_id), productions in mo_by_picking_type_and_company_id.items(): + boms_by_product = self.env['mrp.bom']._bom_find(productions.product_id, picking_type=picking_type, company_id=company_id, bom_type='normal') + for production in productions: + if not production.bom_id or production.bom_id.product_tmpl_id != production.product_tmpl_id or (production.bom_id.product_id and production.bom_id.product_id != production.product_id): + bom = boms_by_product[production.product_id] + production.bom_id = bom.id or False + + @api.depends('bom_id') + def _compute_product_qty(self): + for production in self: + if production.state != 'draft': + continue + if production.bom_id and production._origin.bom_id != production.bom_id: + production.product_qty = production.bom_id.product_qty + @api.depends('move_finished_ids.date_deadline') def _compute_date_deadline(self): for production in self: production.date_deadline = min(production.move_finished_ids.filtered('date_deadline').mapped('date_deadline'), default=production.date_deadline or False) - def _set_date_deadline(self): - for production in self: - production.move_finished_ids.date_deadline = production.date_deadline - @api.depends('workorder_ids.duration_expected') def _compute_production_duration_expected(self): for production in self: @@ -389,26 +423,6 @@ class MrpProduction(models.Model): ]) order.delivery_count = len(order.picking_ids) - def action_view_mo_delivery(self): - """ This function returns an action that display picking related to - manufacturing order orders. It can either be a in a list or in a form - view, if there is only one picking to show. - """ - self.ensure_one() - action = self.env["ir.actions.actions"]._for_xml_id("stock.action_picking_tree_all") - pickings = self.mapped('picking_ids') - if len(pickings) > 1: - action['domain'] = [('id', 'in', pickings.ids)] - elif pickings: - form_view = [(self.env.ref('stock.view_picking_form').id, 'form')] - if 'views' in action: - action['views'] = form_view + [(state,view) for state,view in action['views'] if view != 'form'] - else: - action['views'] = form_view - action['res_id'] = pickings.id - action['context'] = dict(self._context, default_origin=self.name) - return action - @api.depends('product_uom_id', 'product_qty', 'product_id.uom_id') def _compute_product_uom_qty(self): for production in self: @@ -477,6 +491,49 @@ class MrpProduction(models.Model): elif any(not float_is_zero(move.quantity_done, precision_rounding=move.product_uom.rounding or move.product_id.uom_id.rounding) for move in production.move_raw_ids): production.state = 'progress' + @api.depends('bom_id', 'product_id', 'product_qty', 'product_uom_id') + def _compute_workorder_ids(self): + for production in self: + if production.state != 'draft': + continue + workorders_list = [Command.link(wo.id) for wo in production.workorder_ids.filtered(lambda wo: not wo.operation_id)] + if not production.bom_id and not production._origin.product_id: + production.workorder_ids = workorders_list + if production.product_id != production._origin.product_id: + production.workorder_ids = [Command.clear()] + if production.bom_id and production.product_id and production.product_qty > 0: + # keep manual entries + workorders_values = [] + product_qty = production.product_uom_id._compute_quantity(production.product_qty, production.bom_id.product_uom_id) + exploded_boms, dummy = production.bom_id.explode(production.product_id, product_qty / production.bom_id.product_qty, picking_type=production.bom_id.picking_type_id) + + for bom, bom_data in exploded_boms: + # If the operations of the parent BoM and phantom BoM are the same, don't recreate work orders. + if not (bom.operation_ids and (not bom_data['parent_line'] or bom_data['parent_line'].bom_id.operation_ids != bom.operation_ids)): + continue + for operation in bom.operation_ids: + if operation._skip_operation_line(bom_data['product']): + continue + workorders_values += [{ + 'name': operation.name, + 'production_id': production.id, + 'workcenter_id': operation.workcenter_id.id, + 'product_uom_id': production.product_uom_id.id, + 'operation_id': operation.id, + 'state': 'pending', + }] + workorders_dict = {wo.operation_id.id: wo for wo in production.workorder_ids.filtered(lambda wo: wo.operation_id)} + for workorder_values in workorders_values: + if workorder_values['operation_id'] in workorders_dict: + # update existing entries + workorders_list += [Command.update(workorders_dict[workorder_values['operation_id']].id, workorder_values)] + else: + # add new entries + workorders_list += [Command.create(workorder_values)] + production.workorder_ids = workorders_list + else: + production.workorder_ids = [Command.delete(wo.id) for wo in production.workorder_ids.filtered(lambda wo: wo.operation_id)] + @api.depends('state', 'move_raw_ids.state') def _compute_reservation_state(self): self.reservation_state = False @@ -528,6 +585,7 @@ class MrpProduction(models.Model): def _set_move_byproduct_ids(self): move_finished_ids = self.move_finished_ids.filtered(lambda m: m.product_id == self.product_id) + # TODO: Try to create by-product moves here instead of moving them in the `create`. self.move_finished_ids = move_finished_ids | self.move_byproduct_ids @api.depends('state') @@ -577,11 +635,6 @@ class MrpProduction(models.Model): ('move_orig_ids', 'in', lines.ids)], limit=1): mo.show_allocation = True - _sql_constraints = [ - ('name_uniq', 'unique(name, company_id)', 'Reference must be unique per Company!'), - ('qty_positive', 'check (product_qty > 0)', 'The quantity to produce must be positive!'), - ] - @api.depends('product_uom_qty', 'date_planned_start') def _compute_forecasted_issue(self): for order in self: @@ -599,127 +652,63 @@ class MrpProduction(models.Model): late_stock_moves = self.env['stock.move'].search([('delay_alert_date', operator, value)]) return ['|', ('move_raw_ids', 'in', late_stock_moves.ids), ('move_finished_ids', 'in', late_stock_moves.ids)] - @api.onchange('company_id') - def _onchange_company_id(self): - if self.company_id: - if self.move_raw_ids: - self.move_raw_ids.update({'company_id': self.company_id}) - if self.picking_type_id and self.picking_type_id.company_id != self.company_id: - self.picking_type_id = self.env['stock.picking.type'].search([ - ('code', '=', 'mrp_operation'), - ('warehouse_id.company_id', '=', self.company_id.id), - ], limit=1).id - - @api.onchange('product_id', 'picking_type_id', 'company_id') - def _onchange_product_id(self): - """ Finds UoM of changed product. """ - if not self.product_id: - self.bom_id = False - elif not self.bom_id or self.bom_id.product_tmpl_id != self.product_tmpl_id or (self.bom_id.product_id and self.bom_id.product_id != self.product_id): - bom = self.env['mrp.bom']._bom_find(self.product_id, picking_type=self.picking_type_id, company_id=self.company_id.id, bom_type='normal')[self.product_id] - if bom: - self.bom_id = bom.id - self.product_qty = self.bom_id.product_qty - self.product_uom_id = self.bom_id.product_uom_id.id - else: - self.bom_id = False - self.product_uom_id = self.product_id.uom_id.id - - @api.onchange('product_qty', 'product_uom_id') - def _onchange_product_qty(self): - for workorder in self.workorder_ids: - workorder.product_uom_id = self.product_uom_id - if self._origin.product_qty: - workorder.duration_expected = workorder._get_duration_expected(ratio=self.product_qty / self._origin.product_qty) - else: - workorder.duration_expected = workorder._get_duration_expected() - if workorder.date_planned_start and workorder.duration_expected: - workorder.date_planned_finished = workorder.date_planned_start + relativedelta(minutes=workorder.duration_expected) - - @api.onchange('bom_id') - def _onchange_bom_id(self): - if not self.product_id and self.bom_id: - self.product_id = self.bom_id.product_id or self.bom_id.product_tmpl_id.product_variant_ids[:1] - self.product_qty = self.bom_id.product_qty or 1.0 - self.product_uom_id = self.bom_id and self.bom_id.product_uom_id.id or self.product_id.uom_id.id - self.move_raw_ids = [(2, move.id) for move in self.move_raw_ids.filtered(lambda m: m.bom_line_id)] - self.move_finished_ids = [(2, move.id) for move in self.move_finished_ids] - self.picking_type_id = self.bom_id.picking_type_id or self.picking_type_id - - @api.onchange('date_planned_start', 'product_id') - def _onchange_date_planned_start(self): - if self.date_planned_start and not self.is_planned: - date_planned_finished = self.date_planned_start + relativedelta(days=self.product_id.produce_delay) - date_planned_finished = date_planned_finished + relativedelta(days=self.company_id.manufacturing_lead) - if date_planned_finished == self.date_planned_start: + @api.depends('company_id', 'date_planned_start', 'is_planned', 'product_id') + def _compute_date_planned_finished(self): + for production in self: + if not production.date_planned_start or production.is_planned: + continue + days_delay = production.product_id.produce_delay + production.company_id.manufacturing_lead + date_planned_finished = production.date_planned_start + relativedelta(days=days_delay) + if date_planned_finished == production.date_planned_start: date_planned_finished = date_planned_finished + relativedelta(hours=1) - self.date_planned_finished = date_planned_finished - self.move_raw_ids = [(1, m.id, {'date': self.date_planned_start}) for m in self.move_raw_ids] - self.move_finished_ids = [(1, m.id, {'date': date_planned_finished}) for m in self.move_finished_ids] + production.date_planned_finished = date_planned_finished - @api.onchange('bom_id', 'product_id', 'product_qty', 'product_uom_id') - def _onchange_move_raw(self): - if not self.bom_id and not self._origin.product_id: - return - # Clear move raws if we are changing the product. In case of creation (self._origin is empty), - # we need to avoid keeping incorrect lines, so clearing is necessary too. - if self.product_id != self._origin.product_id: - self.move_raw_ids = [(5,)] - if self.bom_id and self.product_id and self.product_qty > 0: - # keep manual entries - list_move_raw = [(4, move.id) for move in self.move_raw_ids.filtered(lambda m: not m.bom_line_id)] - moves_raw_values = self._get_moves_raw_values() - move_raw_dict = {move.bom_line_id.id: move for move in self.move_raw_ids.filtered(lambda m: m.bom_line_id)} - for move_raw_values in moves_raw_values: - if move_raw_values['bom_line_id'] in move_raw_dict: - # update existing entries - list_move_raw += [(1, move_raw_dict[move_raw_values['bom_line_id']].id, move_raw_values)] - else: - # add new entries - list_move_raw += [(0, 0, move_raw_values)] - self.move_raw_ids = list_move_raw - else: - self.move_raw_ids = [(2, move.id) for move in self.move_raw_ids.filtered(lambda m: m.bom_line_id)] + @api.depends('company_id', 'bom_id', 'product_id', 'product_qty', 'product_uom_id', 'location_src_id', 'date_planned_start') + def _compute_move_raw_ids(self): + for production in self: + if production.state != 'draft': + continue + list_move_raw = [Command.link(move.id) for move in production.move_raw_ids.filtered(lambda m: not m.bom_line_id)] + if not production.bom_id and not production._origin.product_id: + production.move_raw_ids = list_move_raw + if production.product_id != production._origin.product_id: + production.move_raw_ids = [Command.clear()] + if production.bom_id and production.product_id and production.product_qty > 0: + # keep manual entries + moves_raw_values = production._get_moves_raw_values() + move_raw_dict = {move.bom_line_id.id: move for move in production.move_raw_ids.filtered(lambda m: m.bom_line_id)} + for move_raw_values in moves_raw_values: + if move_raw_values['bom_line_id'] in move_raw_dict: + # update existing entries + list_move_raw += [Command.update(move_raw_dict[move_raw_values['bom_line_id']].id, move_raw_values)] + else: + # add new entries + list_move_raw += [Command.create(move_raw_values)] + production.move_raw_ids = list_move_raw + else: + production.move_raw_ids = [Command.delete(move.id) for move in production.move_raw_ids.filtered(lambda m: m.bom_line_id)] - @api.onchange('product_id') - def _onchange_move_finished_product(self): - self.move_finished_ids = [(5,)] - if self.product_id: - self._create_update_move_finished() - - @api.onchange('bom_id', 'product_qty', 'product_uom_id') - def _onchange_move_finished(self): - if self.product_id and self.product_qty > 0: - self._create_update_move_finished() - else: - self.move_finished_ids = [(2, move.id) for move in self.move_finished_ids.filtered(lambda m: m.bom_line_id)] - - @api.onchange('location_src_id', 'move_raw_ids', 'bom_id') - def _onchange_location(self): - source_location = self.location_src_id - self.move_raw_ids.update({ - 'warehouse_id': source_location.warehouse_id.id, - 'location_id': source_location.id, - }) - - @api.onchange('location_dest_id', 'move_finished_ids', 'bom_id') - def _onchange_location_dest(self): - destination_location = self.location_dest_id - update_value_list = [] - for move in self.move_finished_ids: - update_value_list += [(1, move.id, ({ - 'warehouse_id': destination_location.warehouse_id.id, - 'location_dest_id': destination_location.id, - }))] - self.move_finished_ids = update_value_list - - @api.onchange('picking_type_id') - def _onchange_picking_type(self): - if not self.picking_type_id.default_location_src_id or not self.picking_type_id.default_location_dest_id.id: - company_id = self.company_id.id if (self.company_id and self.company_id in self.env.companies) else self.env.company.id - fallback_loc = self.env['stock.warehouse'].search([('company_id', '=', company_id)], limit=1).lot_stock_id - self.location_src_id = self.picking_type_id.default_location_src_id.id or fallback_loc.id - self.location_dest_id = self.picking_type_id.default_location_dest_id.id or fallback_loc.id + @api.depends('product_id', 'bom_id', 'product_qty', 'product_uom_id', 'location_dest_id', 'date_planned_finished') + def _compute_move_finished_ids(self): + for production in self: + if production.state != 'draft': + updated_values = {} + if production.date_planned_finished: + updated_values['date'] = production.date_planned_finished + if production.date_deadline: + updated_values['date_deadline'] = production.date_deadline + if 'date' in updated_values or 'date_deadline' in updated_values: + production.move_finished_ids = [ + Command.update(m.id, updated_values) for m in production.move_finished_ids + ] + continue + production.move_finished_ids = [Command.clear()] + if production.product_id: + production._create_update_move_finished() + else: + production.move_finished_ids = [ + Command.delete(move.id) for move in production.move_finished_ids if move.bom_line_id + ] @api.onchange('qty_producing', 'lot_producing_id') def _onchange_producing(self): @@ -734,12 +723,13 @@ class MrpProduction(models.Model): if message: return {'warning': {'title': _('Warning'), 'message': message}} - @api.onchange('bom_id', 'product_id') - def _onchange_workorder_ids(self): - if self.bom_id and self.product_id: - self._create_workorder() - else: - self.workorder_ids = False + @api.onchange('product_id', 'move_raw_ids') + def _onchange_product_id(self): + for move in self.move_raw_ids: + if self.product_id == move.product_id: + message = _("The component %s should not be the same as the product to produce.", self.product_id.display_name) + self.move_raw_ids = self.move_raw_ids - move + return {'warning': {'title': _('Warning'), 'message': message}} @api.constrains('move_byproduct_ids') def _check_byproducts(self): @@ -749,16 +739,24 @@ class MrpProduction(models.Model): if sum(order.move_byproduct_ids.mapped('cost_share')) > 100: raise ValidationError(_("The total cost share for a manufacturing order's by-products cannot exceed 100.")) - @api.constrains('product_id', 'move_raw_ids') - def _check_production_lines(self): - for production in self: - for move in production.move_raw_ids: - if production.product_id == move.product_id: - raise ValidationError(_("The component %s should not be the same as the product to produce.") % production.product_id.display_name) - def write(self, vals): + if 'move_byproduct_ids' in vals and 'move_finished_ids' not in vals: + vals['move_finished_ids'] = vals.get('move_finished_ids', []) + vals['move_byproduct_ids'] + del vals['move_byproduct_ids'] if 'workorder_ids' in self: production_to_replan = self.filtered(lambda p: p.is_planned) + if 'move_raw_ids' in vals and self.state not in ['draft', 'cancel', 'done']: + # When adding a move raw, it should have the source location's `warehouse_id`. + # Before, it was handle by an onchange, now it's forced if not already in vals. + warehouse_id = self.location_src_id.warehouse_id.id + if vals.get('location_src_id'): + location_source = self.env['stock.location'].browse(vals.get('location_src_id')) + warehouse_id = location_source.warehouse_id.id + for move_vals in vals['move_raw_ids']: + command, _id, field_values = move_vals + if command == Command.CREATE and not field_values.get('warehouse_id', False): + field_values['warehouse_id'] = warehouse_id + res = super(MrpProduction, self).write(vals) for production in self: @@ -799,12 +797,11 @@ class MrpProduction(models.Model): vals['move_finished_ids'] = vals.get('move_finished_ids', []) + vals['move_byproduct_ids'] del vals['move_byproduct_ids'] if not vals.get('name', False) or vals['name'] == _('New'): - picking_type_id = vals.get('picking_type_id') or self._get_default_picking_type() - picking_type_id = self.env['stock.picking.type'].browse(picking_type_id) - if picking_type_id: - vals['name'] = picking_type_id.sequence_id.next_by_id() - else: - vals['name'] = self.env['ir.sequence'].next_by_code('mrp.production') or _('New') + picking_type_id = vals.get('picking_type_id') + if not picking_type_id: + picking_type_id = self._get_default_picking_type_id(vals.get('company_id', self.env.company.id)) + vals['picking_type_id'] = picking_type_id + vals['name'] = self.env['stock.picking.type'].browse(picking_type_id).sequence_id.next_by_id() if not vals.get('procurement_group_id'): procurement_group_vals = self._prepare_procurement_group_vals(vals) vals['procurement_group_id'] = self.env["procurement.group"].create(procurement_group_vals).id @@ -816,15 +813,6 @@ class MrpProduction(models.Model): production._onchange_move_finished() return productions - @api.ondelete(at_uninstall=False) - def _unlink_except_done(self): - if any(production.state == 'done' for production in self): - raise UserError(_('Cannot delete a manufacturing order in done state.')) - not_cancel = self.filtered(lambda m: m.state != 'cancel') - if not_cancel: - productions_name = ', '.join([prod.display_name for prod in not_cancel]) - raise UserError(_('%s cannot be deleted. Try to cancel them before.', productions_name)) - def unlink(self): self.action_cancel() workorders_to_delete = self.workorder_ids.filtered(lambda wo: wo.state != 'done') @@ -845,6 +833,22 @@ class MrpProduction(models.Model): default['move_raw_ids'] = [(0, 0, move.copy_data()[0]) for move in self.move_raw_ids.filtered(lambda m: m.product_qty != 0.0)] return super(MrpProduction, self).copy_data(default=default) + def action_view_mo_delivery(self): + """ Returns an action that display picking related to manufacturing order. + It can either be a list view or in a form view (if there is only one picking to show). + """ + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id("stock.action_picking_tree_all") + if len(self.picking_ids) > 1: + action['domain'] = [('id', 'in', self.picking_ids.ids)] + elif self.picking_ids: + action['res_id'] = self.picking_ids.id + action['views'] = [(self.env.ref('stock.view_picking_form').id, 'form')] + if 'views' in action: + action['views'] += [(state, view) for state, view in action['views'] if view != 'form'] + action['context'] = dict(self._context, default_origin=self.name) + return action + def action_toggle_is_locked(self): self.ensure_one() self.is_locked = not self.is_locked @@ -863,33 +867,12 @@ class MrpProduction(models.Model): action['context']['warehouse'] = warehouse.id return action - def _create_workorder(self): - for production in self: - if not production.bom_id or not production.product_id: - continue - workorders_values = [] - - product_qty = production.product_uom_id._compute_quantity(production.product_qty, production.bom_id.product_uom_id) - exploded_boms, dummy = production.bom_id.explode(production.product_id, product_qty / production.bom_id.product_qty, picking_type=production.bom_id.picking_type_id) - - for bom, bom_data in exploded_boms: - # If the operations of the parent BoM and phantom BoM are the same, don't recreate work orders. - if not (bom.operation_ids and (not bom_data['parent_line'] or bom_data['parent_line'].bom_id.operation_ids != bom.operation_ids)): - continue - for operation in bom.operation_ids: - if operation._skip_operation_line(bom_data['product']): - continue - workorders_values += [{ - 'name': operation.name, - 'production_id': production.id, - 'workcenter_id': operation.workcenter_id.id, - 'product_uom_id': production.product_uom_id.id, - 'operation_id': operation.id, - 'state': 'pending', - }] - production.workorder_ids = [(5, 0)] + [(0, 0, value) for value in workorders_values] - for workorder in production.workorder_ids: - workorder.duration_expected = workorder._get_duration_expected() + @api.model + def _get_default_picking_type_id(self, company_id): + return self.env['stock.picking.type'].search([ + ('code', '=', 'mrp_operation'), + ('warehouse_id.company_id', '=', company_id), + ], limit=1).id def _get_move_finished_values(self, product_id, product_uom_qty, product_uom, operation_id=False, byproduct_id=False, cost_share=0): group_orders = self.procurement_group_id.mrp_production_ids @@ -902,7 +885,7 @@ class MrpProduction(models.Model): 'product_uom': product_uom, 'operation_id': operation_id, 'byproduct_id': byproduct_id, - 'name': self.name, + 'name': _('New'), 'date': self._get_date_planned_finished(), 'date_deadline': self.date_deadline, 'picking_type_id': self.picking_type_id.id, @@ -911,7 +894,7 @@ class MrpProduction(models.Model): 'company_id': self.company_id.id, 'production_id': self.id, 'warehouse_id': self.location_dest_id.warehouse_id.id, - 'origin': self.name, + 'origin': self.product_id.partner_ref, 'group_id': self.procurement_group_id.id, 'propagate_cancel': self.propagate_cancel, 'move_dest_ids': [(4, x.id) for x in self.move_dest_ids if not byproduct_id], @@ -946,9 +929,6 @@ class MrpProduction(models.Model): It is important that the special *2Many commands used here remain as long as function is used within onchanges. """ - # keep manual entries - list_move_finished = [(4, move.id) for move in self.move_finished_ids.filtered( - lambda m: not m.byproduct_id and m.product_id != self.product_id)] list_move_finished = [] moves_finished_values = self._get_moves_finished_values() moves_byproduct_dict = {move.byproduct_id.id: move for move in self.move_finished_ids.filtered(lambda m: m.byproduct_id)} @@ -956,12 +936,12 @@ class MrpProduction(models.Model): for move_finished_values in moves_finished_values: if move_finished_values.get('byproduct_id') in moves_byproduct_dict: # update existing entries - list_move_finished += [(1, moves_byproduct_dict[move_finished_values['byproduct_id']].id, move_finished_values)] + list_move_finished += [Command.update(moves_byproduct_dict[move_finished_values['byproduct_id']].id, move_finished_values)] elif move_finished_values.get('product_id') == self.product_id.id and move_finished: - list_move_finished += [(1, move_finished.id, move_finished_values)] + list_move_finished += [Command.update(move_finished.id, move_finished_values)] else: # add new entries - list_move_finished += [(0, 0, move_finished_values)] + list_move_finished += [Command.create(move_finished_values)] self.move_finished_ids = list_move_finished def _get_moves_raw_values(self): @@ -993,7 +973,7 @@ class MrpProduction(models.Model): source_location = self.location_src_id data = { 'sequence': bom_line.sequence if bom_line else 10, - 'name': self.name, + 'name': _('New'), 'date': self.date_planned_start, 'date_deadline': self.date_planned_start, 'bom_line_id': bom_line.id if bom_line else False, @@ -1066,6 +1046,15 @@ class MrpProduction(models.Model): self.env['procurement.group'].run(procurements) return update_info + @api.ondelete(at_uninstall=False) + def _unlink_except_done(self): + if any(production.state == 'done' for production in self): + raise UserError(_('Cannot delete a manufacturing order in done state.')) + not_cancel = self.filtered(lambda m: m.state != 'cancel') + if not_cancel: + productions_name = ', '.join([prod.display_name for prod in not_cancel]) + raise UserError(_('%s cannot be deleted. Try to cancel them before.', productions_name)) + def _get_ready_to_produce_state(self): """ returns 'assigned' if enough components are reserved in order to complete the first operation of the bom. If not returns 'waiting' @@ -1997,15 +1986,12 @@ class MrpProduction(models.Model): production = self.env['mrp.production'].create({ 'product_id': product_id.id, 'bom_id': bom_id.id, - 'picking_type_id': bom_id.picking_type_id or self._get_default_picking_type(), + 'picking_type_id': bom_id.picking_type_id or self._get_default_picking_type_id(self.env.company.id), 'product_qty': sum(production.product_uom_qty for production in self), 'product_uom_id': product_id.uom_id.id, 'user_id': user_id.id, 'origin': ",".join(sorted([production.name for production in self])), }) - self.env['stock.move'].create(production._get_moves_raw_values()) - self.env['stock.move'].create(production._get_moves_finished_values()) - production._create_workorder() for move in production.move_raw_ids: for field, vals in origs[move.bom_line_id.id].items(): diff --git a/addons/mrp/models/mrp_workorder.py b/addons/mrp/models/mrp_workorder.py index 69187efaf94..7a85d10d996 100644 --- a/addons/mrp/models/mrp_workorder.py +++ b/addons/mrp/models/mrp_workorder.py @@ -91,9 +91,9 @@ class MrpWorkorder(models.Model): states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}) duration_expected = fields.Float( - 'Expected Duration', digits=(16, 2), default=60.0, + 'Expected Duration', digits=(16, 2), default=60.0, compute='_compute_duration_expected', states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, - help="Expected duration (in minutes)") + readonly=False, store=True, help="Expected duration (in minutes)") duration = fields.Float( 'Real Duration', compute='_compute_duration', inverse='_set_duration', readonly=False, store=True, copy=False) @@ -299,6 +299,11 @@ class MrpWorkorder(models.Model): 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_production') + def _compute_duration_expected(self): + for workorder in self: + workorder.duration_expected = workorder._get_duration_expected() + @api.depends('time_ids.duration', 'qty_produced') def _compute_duration(self): for order in self: @@ -408,12 +413,8 @@ class MrpWorkorder(models.Model): compute_leaves=True, domain=[('time_type', 'in', ['leave', 'other'])] ) - @api.onchange('operation_id', 'workcenter_id', 'qty_production') - def _onchange_expected_duration(self): - self.duration_expected = self._get_duration_expected() - def write(self, values): - if 'production_id' in 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: diff --git a/addons/mrp/models/stock_rule.py b/addons/mrp/models/stock_rule.py index d545e478f99..8af591ea519 100644 --- a/addons/mrp/models/stock_rule.py +++ b/addons/mrp/models/stock_rule.py @@ -40,7 +40,6 @@ class StockRule(models.Model): @api.model def _run_manufacture(self, procurements): productions_values_by_company = defaultdict(list) - errors = [] 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. @@ -49,15 +48,9 @@ class StockRule(models.Model): productions_values_by_company[procurement.company_id.id].append(rule._prepare_mo_vals(*procurement, bom)) - if errors: - raise ProcurementException(errors) - 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) - self.env['stock.move'].sudo().create(productions._get_moves_raw_values()) - self.env['stock.move'].sudo().create(productions._get_moves_finished_values()) - productions._create_workorder() productions.filtered(lambda p: not p.orderpoint_id or not p.move_raw_ids).action_confirm() for production in productions: diff --git a/addons/mrp/populate/mrp.py b/addons/mrp/populate/mrp.py index bf7a708a770..97032e8f640 100644 --- a/addons/mrp/populate/mrp.py +++ b/addons/mrp/populate/mrp.py @@ -271,17 +271,6 @@ class MrpProduction(models.Model): def _populate(self, size): productions = super()._populate(size) - def fill_mo_with_bom_info(): - productions_with_bom = productions.filtered('bom_id') - _logger.info("Create Raw moves of MO(s) with bom (%d)" % len(productions_with_bom)) - self.env['stock.move'].create(productions_with_bom._get_moves_raw_values()) - _logger.info("Create Finished moves of MO(s) with bom (%d)" % len(productions_with_bom)) - self.env['stock.move'].create(productions_with_bom._get_moves_finished_values()) - _logger.info("Create Workorder moves of MO(s) with bom (%d)" % len(productions_with_bom)) - productions_with_bom._create_workorder() - - fill_mo_with_bom_info() - def confirm_bom_mo(sample_ratio): # Confirm X % of prototype MO random = populate.Random('confirm_bom_mo') diff --git a/addons/mrp/tests/test_backorder.py b/addons/mrp/tests/test_backorder.py index bdd1799f36b..6dd33195823 100644 --- a/addons/mrp/tests/test_backorder.py +++ b/addons/mrp/tests/test_backorder.py @@ -316,13 +316,11 @@ class TestMrpProductionBackorder(TestMrpCommon): backorder.save().action_backorder() return mo.procurement_group_id.mrp_production_ids[-1] - default_picking_type_id = self.env['mrp.production']._get_default_picking_type() + default_picking_type_id = self.env['mrp.production']._get_default_picking_type_id(self.env.company.id) default_picking_type = self.env['stock.picking.type'].browse(default_picking_type_id) mo_sequence = default_picking_type.sequence_id - mo_sequence.prefix = "WH-MO-" initial_mo_name = mo_sequence.prefix + str(mo_sequence.number_next_actual).zfill(mo_sequence.padding) - production = self.generate_mo(qty_final=5)[0] self.assertEqual(production.name, initial_mo_name) @@ -331,7 +329,6 @@ class TestMrpProductionBackorder(TestMrpCommon): self.assertEqual(backorder.name, initial_mo_name + "-002") backorder.backorder_sequence = 998 - for seq in [998, 999, 1000]: new_backorder = produce_one(backorder) self.assertEqual(backorder.name, initial_mo_name + "-" + str(seq)) @@ -425,7 +422,7 @@ class TestMrpProductionBackorder(TestMrpCommon): 'location_id': self.stock_location.id, })._apply_inventory() - default_picking_type_id = self.env['mrp.production']._get_default_picking_type() + default_picking_type_id = self.env['mrp.production']._get_default_picking_type_id(self.env.company.id) default_picking_type = self.env['stock.picking.type'].browse(default_picking_type_id) # make sure generated MO will auto-assign diff --git a/addons/mrp/tests/test_order.py b/addons/mrp/tests/test_order.py index 304e461aa88..6be0eca0919 100644 --- a/addons/mrp/tests/test_order.py +++ b/addons/mrp/tests/test_order.py @@ -337,6 +337,7 @@ class TestMrpOrder(TestMrpCommon): self.assertEqual(production.workorder_ids.duration_expected, 165) def test_update_quantity_4(self): + """ Workcenter 1 has 10' start time and 5' stop time """ bom = self.env['mrp.bom'].create({ 'product_id': self.product_6.id, 'product_tmpl_id': self.product_6.product_tmpl_id.id, @@ -364,6 +365,14 @@ class TestMrpOrder(TestMrpCommon): mo_form = Form(production) mo_form.product_qty = 3 production = mo_form.save() + self.assertEqual(production.workorder_ids.duration_expected, 40) + + production.action_confirm() + update_quantity_wizard = self.env['change.production.qty'].create({ + 'mo_id': production.id, + 'product_qty': 9, + }) + update_quantity_wizard.change_prod_qty() self.assertEqual(production.workorder_ids.duration_expected, 90) def test_update_quantity_5(self): @@ -549,8 +558,7 @@ class TestMrpOrder(TestMrpCommon): self.assertEqual(mo.move_finished_ids[0].date, datetime(2023, 5, 15, 10, 0)) mo.action_confirm() mo.button_plan() - with Form(mo) as frm: - frm.date_planned_start = datetime(2024, 5, 15, 9, 0) + mo.date_planned_start = datetime(2024, 5, 15, 9, 0) self.assertEqual(mo.move_finished_ids[0].date, datetime(2024, 5, 15, 10, 0)) def test_rounding(self): diff --git a/addons/mrp/tests/test_procurement.py b/addons/mrp/tests/test_procurement.py index 81afaa8baf0..d2f4743f2b5 100644 --- a/addons/mrp/tests/test_procurement.py +++ b/addons/mrp/tests/test_procurement.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from datetime import datetime, timedelta +from datetime import timedelta from odoo import fields from odoo.tests import Form @@ -250,9 +250,7 @@ class TestProcurement(TestMrpCommon): self.assertEqual(move_orig.product_qty, 10, 'the quantity to produce is not good relative to the move') new_sheduled_date = fields.Datetime.to_datetime(mo.date_planned_start) + timedelta(days=5) - mo_form = Form(mo) - mo_form.date_planned_start = new_sheduled_date - mo = mo_form.save() + mo.date_planned_start = new_sheduled_date self.assertAlmostEqual(mo.move_raw_ids.date, mo.date_planned_start, delta=timedelta(seconds=1)) self.assertAlmostEqual(mo.move_finished_ids.date, mo.date_planned_finished, delta=timedelta(seconds=1)) diff --git a/addons/mrp/views/mrp_production_views.xml b/addons/mrp/views/mrp_production_views.xml index e29d9b80e0e..11b4c8a4d98 100644 --- a/addons/mrp/views/mrp_production_views.xml +++ b/addons/mrp/views/mrp_production_views.xml @@ -211,7 +211,7 @@