diff --git a/addons/mrp/data/mrp_demo.xml b/addons/mrp/data/mrp_demo.xml index fb984452588..7295121a19d 100644 --- a/addons/mrp/data/mrp_demo.xml +++ b/addons/mrp/data/mrp_demo.xml @@ -672,6 +672,10 @@ + + + + diff --git a/addons/mrp/models/mrp_abstract_workorder.py b/addons/mrp/models/mrp_abstract_workorder.py index fafd11d3c85..0dc6d8fae58 100644 --- a/addons/mrp/models/mrp_abstract_workorder.py +++ b/addons/mrp/models/mrp_abstract_workorder.py @@ -54,8 +54,6 @@ class MrpAbstractWorkorder(models.AbstractModel): workorder line in order to match the new quantity to consume for each component and their reserved quantity. """ - if self.qty_producing <= 0: - raise UserError(_('You have to produce at least one %s.') % self.product_uom_id.name) line_values = self._update_workorder_lines() for values in line_values['to_create']: self.env[self._workorder_line_ids()._name].new(values) diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 76beb12b253..e6610ce1190 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -146,7 +146,6 @@ class MrpProduction(models.Model): state = fields.Selection([ ('draft', 'Draft'), ('confirmed', 'Confirmed'), - ('planned', 'Planned'), ('progress', 'In Progress'), ('to_close', 'To Close'), ('done', 'Done'), @@ -155,7 +154,6 @@ class MrpProduction(models.Model): store=True, tracking=True, help=" * Draft: The MO is not confirmed yet.\n" " * Confirmed: The MO is confirmed, the stock rules and the reordering of the components are trigerred.\n" - " * Planned: The WO are planned.\n" " * In Progress: The production has started (on the MO or on the WO).\n" " * To Close: The production is done, the MO has to be closed.\n" " * Done: The MO is closed, the stock moves are posted. \n" @@ -185,8 +183,7 @@ class MrpProduction(models.Model): 'stock.move.line', compute='_compute_lines', inverse='_inverse_lines', string="Finished Product" ) workorder_ids = fields.One2many( - 'mrp.workorder', 'production_id', 'Work Orders', - copy=False, readonly=True) + 'mrp.workorder', 'production_id', 'Work Orders', copy=True) workorder_count = fields.Integer('# Work Orders', compute='_compute_workorder_count') workorder_done_count = fields.Integer('# Done Work Orders', compute='_compute_workorder_done_count') move_dest_ids = fields.One2many('stock.move', 'created_production_id', @@ -227,6 +224,7 @@ class MrpProduction(models.Model): priority = fields.Selection([('0', 'Not urgent'), ('1', 'Normal'), ('2', 'Urgent'), ('3', 'Very Urgent')], 'Priority', readonly=True, states={'draft': [('readonly', False)]}, default='1') is_locked = fields.Boolean('Is Locked', default=True, copy=False) + is_planned = fields.Boolean('Its Operations are Planned', compute="_compute_is_planned") show_final_lots = fields.Boolean('Show Final Lots', compute='_compute_show_lots') production_location_id = fields.Many2one('stock.location', "Production Location", related='product_id.property_stock_production', readonly=False, related_sudo=False) picking_ids = fields.Many2many('stock.picking', compute='_compute_picking_ids', string='Picking associated to this manufacturing order') @@ -296,6 +294,13 @@ class MrpProduction(models.Model): else: production.bom_has_operations = len(production.bom_id.operation_ids) > 0 + def _compute_is_planned(self): + for production in self: + if production.workorder_ids: + production.is_planned = all(wo.date_planned_start and wo.date_planned_finished for wo in production.workorder_ids) + else: + production.is_planned = False + @api.depends('move_raw_ids.delay_alert_date') def _compute_delay_alert_date(self): delay_alert_date_data = self.env['stock.move'].read_group([('id', 'in', self.move_raw_ids.ids), ('delay_alert_date', '!=', False)], ['delay_alert_date:max'], 'raw_material_production_id') @@ -403,14 +408,12 @@ class MrpProduction(models.Model): production.workorder_done_count = count_data.get(production.id, 0) @api.depends( - 'move_raw_ids.state', 'move_finished_ids.state', 'workorder_ids', - 'workorder_ids.date_planned_start', 'workorder_ids.date_planned_finished', - 'workorder_ids.state', 'move_raw_ids.quantity_done', 'product_qty', 'qty_producing') + 'move_raw_ids.state', 'move_raw_ids.quantity_done', 'move_finished_ids.state', + 'workorder_ids', 'workorder_ids.state', 'product_qty', 'qty_producing') def _compute_state(self): """ Compute the production state. It use the same process than stock picking. It exists 3 extra steps for production: - - planned: Workorder has been launched (workorders only) - - progress: At least one item is produced. + - progress: At least one item is produced or consumed. - to_close: The quantity produced is greater than the quantity to produce and all work orders has been finished. """ @@ -422,22 +425,16 @@ class MrpProduction(models.Model): production.state = 'draft' elif all(move.state == 'cancel' for move in production.move_raw_ids): production.state = 'cancel' - elif all(move.state in ['cancel', 'done'] for move in production.move_raw_ids): - if ( - production.bom_id.consumption == 'flexible' - and float_compare(production.qty_producing, production.product_qty, precision_rounding=production.product_uom_id.rounding) == -1 - ): - production.state = 'progress' - else: - production.state = 'done' + elif all(move.state in ('cancel', 'done') for move in production.move_raw_ids): + production.state = 'done' elif production.qty_producing >= production.product_qty: production.state = 'to_close' - elif production.workorder_ids and any(wo_state == 'progress' for wo_state in production.workorder_ids.mapped('state')): + elif any(wo_state in ('progress', 'done') for wo_state in production.workorder_ids.mapped('state')): production.state = 'progress' - elif not float_is_zero(production.qty_producing, precision_rounding=production.product_uom_id.rounding) and production.qty_producing < production.product_qty: + elif not float_is_zero(production.qty_producing, precision_rounding=production.product_uom_id.rounding): + production.state = 'progress' + elif any(not float_is_zero(move.quantity_done, precision_rounding=move.product_uom.rounding) for move in production.move_raw_ids) : production.state = 'progress' - elif production.workorder_ids: - production.state = 'planned' else: production.state = 'confirmed' @@ -522,6 +519,17 @@ class MrpProduction(models.Model): 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): + product_qty = self.product_uom_id._compute_quantity(self.product_qty, self.product_uom_id) + for workorder in self.workorder_ids: + if not workorder.product_uom_id: + workorder.product_uom_id = self.product_uom_id + workorder.qty_producing = self.product_id.tracking == 'serial' and 1.0 or product_qty + 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: @@ -533,7 +541,7 @@ class MrpProduction(models.Model): @api.onchange('date_planned_start') def _onchange_date_planned_start(self): - if not self.bom_id.operation_ids: + if not self.workorder_ids: self.date_planned_finished = self.date_planned_start + datetime.timedelta(hours=1) @api.onchange('bom_id', 'product_id', 'product_qty', 'product_uom_id') @@ -595,6 +603,11 @@ class MrpProduction(models.Model): for move_line, res in vals['to_write']: move_line.update(res) + @api.onchange('bom_id') + def _onchange_workorder_ids(self): + if self.bom_id: + self._create_workorder() + def write(self, vals): res = super(MrpProduction, self).write(vals) @@ -602,7 +615,7 @@ class MrpProduction(models.Model): if 'date_planned_start' in vals: if production.state in ['done', 'cancel']: raise UserError(_('You cannot move a manufacturing order once it is cancelled or done.')) - if production.workorder_ids and not self.env.context.get('force_date', False): + if production.state not in ('draft', 'confirmed') and production.workorder_ids and not self.env.context.get('force_date', False): raise UserError(_('You cannot move a planned manufacturing order.')) if ('move_raw_ids' in vals or 'move_finished_ids' in vals) and production.state != 'draft': production._autoconfirm_production() @@ -652,6 +665,34 @@ class MrpProduction(models.Model): self.is_locked = not self.is_locked return True + def _create_workorder(self): + for production in self: + if not production.bom_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: + workorders_values += [{ + 'name': operation.name, + 'production_id': production.id, + 'workcenter_id': operation.workcenter_id.id, + 'product_uom_id': production.product_id.uom_id.id, + 'operation_id': operation.id, + 'state': 'pending', + 'qty_producing': production.product_id.tracking == 'serial' and 1.0 or product_qty, + 'consumption': production.consumption, + }] + 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() + def _get_finished_move_value(self, product_id, product_uom_qty, product_uom, operation_id=False, byproduct_id=False): group_orders = self.procurement_group_id.mrp_production_ids move_dest_ids = self.move_dest_ids @@ -823,6 +864,8 @@ class MrpProduction(models.Model): if moves_to_confirm: moves_to_confirm._action_confirm() + self.workorder_ids.filtered(lambda w: w.state not in ['done', 'cancel'])._action_confirm() + def action_view_mrp_production_childs(self): self.ensure_one() mrp_production_ids = self.procurement_group_id.stock_move_ids.created_production_id.procurement_group_id.mrp_production_ids.ids @@ -911,17 +954,17 @@ class MrpProduction(models.Model): production._generate_finished_moves() production.move_raw_ids._adjust_procure_method() (production.move_raw_ids | production.move_finished_ids)._action_confirm() + production.workorder_ids._action_confirm() return True def action_assign(self): for production in self: production.move_raw_ids._action_assign() - production.workorder_ids._refresh_wo_lines() return True def button_plan(self): """ Create work orders. And probably do stuff, like things. """ - orders_to_plan = self.filtered(lambda order: order.bom_id.operation_ids and order.state == 'confirmed') + orders_to_plan = self.filtered(lambda order: not order.is_planned) for order in orders_to_plan: order.move_raw_ids.filtered(lambda m: m.state == 'draft')._action_confirm() # `propagate_date` enables the automatic rescheduling which could lead to hard to @@ -929,9 +972,6 @@ class MrpProduction(models.Model): # have their leaves booked in the workcenter calendar. We thus disable the # automatic rescheduling in this scenario. order.move_raw_ids.write({'propagate_date': False}) - quantity = order.product_uom_id._compute_quantity(order.product_qty, order.bom_id.product_uom_id) / order.bom_id.product_qty - boms, lines = order.bom_id.explode(order.product_id, quantity, picking_type=order.bom_id.picking_type_id) - order._generate_workorders(boms) order._plan_workorders() return True @@ -963,9 +1003,10 @@ class MrpProduction(models.Model): vals = {} for workcenter in workcenters: # compute theoretical duration - time_cycle = workorder.operation_id.time_cycle - cycle_number = float_round(qty_to_produce / workcenter.capacity, precision_digits=0, rounding_method='UP') - duration_expected = workcenter.time_start + workcenter.time_stop + cycle_number * time_cycle * 100.0 / workcenter.time_efficiency + if workorder.workcenter_id == workcenter: + duration_expected = workorder.duration_expected + else: + duration_expected = workorder._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 @@ -1015,73 +1056,7 @@ class MrpProduction(models.Model): raise UserError(_("Some work orders are already done, you cannot unplan this manufacturing order.")) elif any(wo.state == 'progress' for wo in self.workorder_ids): raise UserError(_("Some work orders have already started, you cannot unplan this manufacturing order.")) - self.workorder_ids.unlink() - - def _generate_workorders(self, exploded_boms): - workorders = self.env['mrp.workorder'] - original_one = False - 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 bom.operation_ids and (not bom_data['parent_line'] or bom_data['parent_line'].bom_id.operation_ids != bom.operation_ids): - temp_workorders = self._workorders_create(bom, bom_data) - workorders += temp_workorders - if temp_workorders: # In order to avoid two "ending work orders" - if original_one: - temp_workorders[-1].next_work_order_id = original_one - original_one = temp_workorders[0] - return workorders - - def _workorders_create(self, bom, bom_data): - """ - :param bom: in case of recursive boms: we could create work orders for child - BoMs - """ - workorders = self.env['mrp.workorder'] - - # Initial qty producing - quantity = max(self.product_qty - sum(self.move_finished_ids.filtered(lambda move: move.product_id == self.product_id).mapped('quantity_done')), 0) - quantity = self.product_id.uom_id._compute_quantity(quantity, self.product_uom_id) - if self.product_id.tracking == 'serial': - quantity = 1.0 - - for operation in bom.operation_ids: - workorder = workorders.create({ - 'name': operation.name, - 'production_id': self.id, - 'workcenter_id': operation.workcenter_id.id, - 'product_uom_id': self.product_id.uom_id.id, - 'operation_id': operation.id, - 'state': len(workorders) == 0 and 'ready' or 'pending', - 'qty_producing': quantity, - 'consumption': self.consumption, - }) - if workorders: - workorders[-1].next_work_order_id = workorder.id - workorders[-1]._start_nextworkorder() - workorders += workorder - - # get the raw moves to attach to this operation - moves_raw = self.env['stock.move'] - for move in self.move_raw_ids: - if move.operation_id == operation: - moves_raw |= move - moves_finished = self.move_finished_ids.filtered(lambda move: move.operation_id == operation) - - # - Raw moves from a BoM where a routing was set but no operation was precised should - # be consumed at the last workorder of the linked routing. - # - Raw moves from a BoM where no rounting was set should be consumed at the last - # workorder of the main routing. - if len(workorders) == len(bom.operation_ids): - moves_raw |= self.move_raw_ids.filtered(lambda move: not move.operation_id and move.bom_line_id.bom_id.operation_ids and move.bom_line_id.bom_id == bom) - moves_raw |= self.move_raw_ids.filtered(lambda move: not move.workorder_id and not move.bom_line_id.bom_id.operation_ids) - - moves_finished |= self.move_finished_ids.filtered(lambda move: move.product_id != self.product_id and not move.operation_id) - - moves_raw.mapped('move_line_ids').write({'workorder_id': workorder.id}) - (moves_finished | moves_raw).write({'workorder_id': workorder.id}) - - workorder._generate_wo_lines() - return workorders + self.workorder_ids.leave_id.unlink() def _get_consumption_issues(self): """Compare the quantity consumed of the components, the expected quantity @@ -1286,7 +1261,7 @@ class MrpProduction(models.Model): production.name = self._get_name_backorder(production.name, production.backorder_sequence) for wo in backorder_mo.workorder_ids: - wo.duration_expected = wo._get_duration_expected(wo.workcenter_id) + wo.duration_expected = wo._get_duration_expected() backorders.action_confirm() # Remove the serial move line without reserved quantity. Post inventory will assigned all the non done moves @@ -1340,7 +1315,7 @@ class MrpProduction(models.Model): production.write({'date_finished': fields.Datetime.now(), 'product_qty': production.qty_produced}) for workorder in self.workorder_ids.filtered(lambda w: w.state not in ('done', 'cancel')): - workorder.duration_expected = workorder._get_duration_expected(workorder.workcenter_id) + workorder.duration_expected = workorder._get_duration_expected() if not backorders: return True diff --git a/addons/mrp/models/mrp_routing.py b/addons/mrp/models/mrp_routing.py index 9104e684221..422ff9449cc 100644 --- a/addons/mrp/models/mrp_routing.py +++ b/addons/mrp/models/mrp_routing.py @@ -17,11 +17,12 @@ class MrpRoutingWorkcenter(models.Model): 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, + index=True, ondelete='cascade', help="The Bill of Material this operation is linked to") company_id = fields.Many2one( 'res.company', 'Company', - readonly=True, related='bom_id.company_id', store=True) + readonly=True, related='bom_id.company_id', store=True, + default=lambda self: self.env.company) worksheet_type = fields.Selection([ ('pdf', 'PDF'), ('google_slide', 'Google Slide'), ('text', 'Text')], string="Work Sheet", default="pdf", diff --git a/addons/mrp/models/mrp_workorder.py b/addons/mrp/models/mrp_workorder.py index 444976ebaed..edb6345f662 100644 --- a/addons/mrp/models/mrp_workorder.py +++ b/addons/mrp/models/mrp_workorder.py @@ -31,7 +31,7 @@ class MrpWorkorder(models.Model): required=True, index=True, readonly=True) workcenter_id = fields.Many2one( 'mrp.workcenter', 'Work Center', required=True, - states={'done': [('readonly', True)], 'cancel': [('readonly', 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', readonly=False, @@ -59,34 +59,32 @@ class MrpWorkorder(models.Model): ('progress', 'In Progress'), ('done', 'Finished'), ('cancel', 'Cancelled')], string='Status', - default='pending') + default='pending', copy=False, readonly=True) leave_id = fields.Many2one( 'resource.calendar.leaves', help='Slot into workcenter calendar once planned', - check_company=True) + check_company=True, copy=False) date_planned_start = fields.Datetime( - 'Scheduled Date Start', + 'Scheduled Start Date', compute='_compute_dates_planned', inverse='_set_dates_planned', states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, - store=True, - tracking=True) + store=True, tracking=True, copy=False) date_planned_finished = fields.Datetime( - 'Scheduled Date Finished', + 'Scheduled End Date', compute='_compute_dates_planned', inverse='_set_dates_planned', states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, - store=True, - tracking=True) + store=True, tracking=True, copy=False) date_start = fields.Datetime( - 'Effective Start Date', + 'Start Date', copy=False, states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}) date_finished = fields.Datetime( - 'Effective End Date', + 'End Date', copy=False, states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}) duration_expected = fields.Float( - 'Expected Duration', digits=(16, 2), + 'Expected Duration', digits=(16, 2), default=60.0, states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, help="Expected duration (in minutes)") duration = fields.Float( @@ -101,8 +99,7 @@ class MrpWorkorder(models.Model): progress = fields.Float('Progress Done (%)', digits=(16, 2), compute='_compute_progress') operation_id = fields.Many2one( - 'mrp.routing.workcenter', 'Operation', - check_company=True) + '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) @@ -124,7 +121,7 @@ class MrpWorkorder(models.Model): 'stock.production.lot', 'Lot/Serial Number', domain="[('id', 'in', allowed_lots_domain)]", states={'done': [('readonly', True)], 'cancel': [('readonly', True)]}, check_company=True) time_ids = fields.One2many( - 'mrp.workcenter.productivity', 'workorder_id') + 'mrp.workcenter.productivity', 'workorder_id', copy=False) is_user_working = fields.Boolean( 'Is the Current User Working', compute='_compute_working_users', help="Technical field indicating whether the current user is working. ") @@ -144,6 +141,7 @@ class MrpWorkorder(models.Model): json_popover = fields.Char('Popover Data JSON', compute='_compute_json_popover') show_json_popover = fields.Boolean('Show Popover?', compute='_compute_json_popover') + @api.depends('production_state', 'date_planned_start', 'date_planned_finished') def _compute_json_popover(self): previous_wo_data = self.env['mrp.workorder'].read_group( [('next_work_order_id', 'in', self.ids)], @@ -154,9 +152,14 @@ class MrpWorkorder(models.Model): 'date_planned_start': x['date_planned_start'], 'date_planned_finished': x['date_planned_finished']}) for x in previous_wo_data]) - conflicted_dict = self._get_conflicted_workorder_ids() + 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', 'ready']: previous_wo = previous_wo_dict.get(wo.id) prev_start = previous_wo and previous_wo['date_planned_start'] or False @@ -222,24 +225,6 @@ class MrpWorkorder(models.Model): else: workorder.is_finished_lines_editable = False - @api.onchange('finished_lot_id') - def _onchange_finished_lot_id(self): - """When the user changes the lot being currently produced, suggest - a quantity to produce consistent with the previous workorders. """ - previous_wo = self.env['mrp.workorder'].search([ - ('next_work_order_id', '=', self.id) - ]) - if previous_wo: - line = previous_wo.finished_workorder_line_ids.filtered(lambda line: line.product_id == self.product_id and line.lot_id == self.finished_lot_id) - if line: - self.qty_producing = line.qty_done - - @api.onchange('date_planned_finished') - def _onchange_date_planned_finished(self): - if self.date_planned_start and self.date_planned_finished: - diff = self.date_planned_finished - self.date_planned_start - self.duration_expected = diff.total_seconds() / 60 - @api.depends('production_id.workorder_ids.finished_workorder_line_ids', 'production_id.workorder_ids.finished_workorder_line_ids.qty_done', 'production_id.workorder_ids.finished_workorder_line_ids.lot_id') @@ -251,6 +236,8 @@ class MrpWorkorder(models.Model): productions = self.mapped('production_id') treated = self.browse() for production in productions: + if production.state == 'draft': + continue if production.product_id.tracking == 'none': continue @@ -297,12 +284,17 @@ class MrpWorkorder(models.Model): # 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() - return super(MrpWorkorder, self).unlink() + mo_dirty = self.production_id.filtered(lambda mo: mo.state in ("confirmed", "progress", "to_close")) + 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') + @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): + 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 @@ -347,11 +339,39 @@ class MrpWorkorder(models.Model): for workorder in self: workorder.scrap_count = count_data.get(workorder.id, 0) + @api.onchange('finished_lot_id') + def _onchange_finished_lot_id(self): + """When the user changes the lot being currently produced, suggest + a quantity to produce consistent with the previous workorders. """ + previous_wo = self.env['mrp.workorder'].search([ + ('next_work_order_id', '=', self.id) + ]) + if previous_wo: + line = previous_wo.finished_workorder_line_ids.filtered(lambda line: line.product_id == self.product_id and line.lot_id == self.finished_lot_id) + if line: + self.qty_producing = line.qty_done + + @api.onchange('date_planned_finished') + def _onchange_date_planned_finished(self): + if self.date_planned_start and self.date_planned_finished: + diff = self.date_planned_finished - self.date_planned_start + self.duration_expected = diff.total_seconds() / 60 + + @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') def _onchange_date_planned_start(self): if self.date_planned_start and self.duration_expected: self.date_planned_finished = self.date_planned_start + relativedelta(minutes=self.duration_expected) + @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: raise UserError(_('You cannot link this work order to another manufacturing order.')) @@ -361,7 +381,7 @@ class MrpWorkorder(models.Model): 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 list(values.keys()) != ['time_ids'] and any(workorder.state == 'done' for workorder in self): + if any(k not in ['time_ids', 'duration_expected', 'next_work_order_id'] for k in values.keys()) and any(workorder.state == 'done' for workorder in self): raise UserError(_('You can not change the finished work order.')) if 'date_planned_start' in values or 'date_planned_finished' in values: for workorder in self: @@ -381,6 +401,67 @@ class MrpWorkorder(models.Model): }) 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. + 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): + workorders_by_production = defaultdict(lambda: self.env['mrp.workorder']) + for workorder in self: + workorders_by_production[workorder.production_id] |= workorder + + for production, workorders in workorders_by_production.items(): + workorders_by_bom = defaultdict(lambda: self.env['mrp.workorder']) + bom = self.env['mrp.bom'] + moves = production.move_raw_ids | production.move_finished_ids + + for workorder in self: + if workorder.operation_id.bom_id: + bom = workorder.operation_id.bom_id + if not bom: + bom = workorder.production_id.bom_id + previous_workorder = workorders_by_bom[bom][-1:] + previous_workorder.next_work_order_id = workorder.id + workorders_by_bom[bom] |= workorder + + moves.filtered(lambda m: m.operation_id == workorder.operation_id).write({ + 'workorder_id': workorder.id + }) + + exploded_boms, dummy = production.bom_id.explode(production.product_id, 1, picking_type=production.bom_id.picking_type_id) + exploded_boms = {b[0]: b[1] for b in exploded_boms} + for move in moves: + if move.workorder_id: + continue + bom = move.bom_line_id.bom_id + while bom and bom not in workorders_by_bom: + bom_data = exploded_boms.get(bom, {}) + bom = bom_data.get('parent_line') and bom_data['parent_line'].bom_id or False + if bom in workorders_by_bom: + move.write({ + 'workorder_id': workorders_by_bom[bom][-1:].id + }) + else: + move.write({ + 'workorder_id': workorders_by_bom[production.bom_id][-1:].id + }) + + for workorder in workorders: + workorder._apply_update_workorder_lines() + + for workorders in workorders_by_bom.values(): + if workorders[0].state == 'pending': + workorders[0].state = 'ready' + for workorder in workorders: + workorder._start_nextworkorder() + def _generate_wo_lines(self): """ Generate workorder line """ self.ensure_one() @@ -570,6 +651,8 @@ class MrpWorkorder(models.Model): (self.operation_id.batch == 'yes' and float_compare(self.operation_id.batch_size, self.qty_produced, precision_rounding=rounding) <= 0)): self.next_work_order_id.state = 'ready' + if self.state == 'done' and self.next_work_order_id.state == 'pending': + self.next_work_order_id.state = 'ready' @api.model def gantt_unavailability(self, start_date, end_date, scale, group_bys=None, rows=None): @@ -617,7 +700,7 @@ class MrpWorkorder(models.Model): # Need a loss in case of the real time exceeding the expected timeline = self.env['mrp.workcenter.productivity'] - if self.duration < self.duration_expected: + if not self.duration_expected or self.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.")) @@ -632,7 +715,7 @@ class MrpWorkorder(models.Model): timeline.create({ 'workorder_id': self.id, 'workcenter_id': self.workcenter_id.id, - 'description': _('Time Tracking: ')+self.env.user.name, + 'description': _('Time Tracking: ') + self.env.user.name, 'loss_id': loss_id[0].id, 'date_start': datetime.now(), 'user_id': self.env.user.id, # FIXME sle: can be inconsistent with company_id @@ -640,26 +723,45 @@ class MrpWorkorder(models.Model): }) 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.production_id.name + ' - ' + self.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: - start_date = datetime.now() - vals = { - 'state': 'progress', - 'date_start': start_date, - 'date_planned_start': start_date, - } + vals['date_planned_start'] = start_date if self.date_planned_finished < start_date: vals['date_planned_finished'] = start_date return self.write(vals) def button_finish(self): - self.ensure_one() - self.end_all() end_date = datetime.now() - return self.write({ - 'state': 'done', - 'date_finished': end_date, - 'date_planned_finished': end_date - }) + for workorder in self: + if workorder.state in ('done', 'cancel'): + continue + workorder.end_all() + vals = { + 'state': 'done', + 'date_finished': end_date, + 'date_planned_finished': end_date + } + 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.write(vals) + return True def end_previous(self, doall=False): """ @@ -751,11 +853,32 @@ class MrpWorkorder(models.Model): action['domain'] = [('workorder_id', '=', self.id)] return action + def action_open_wizard(self): + self.ensure_one() + action = self.env.ref('mrp.mrp_workorder_mrp_production_form').read()[0] + action['res_id'] = self.id + return action + @api.depends('qty_production', 'qty_produced') def _compute_qty_remaining(self): for wo in self: wo.qty_remaining = float_round(wo.qty_production - wo.qty_produced, precision_rounding=wo.production_id.product_uom_id.rounding) + def _get_duration_expected(self, alternative_workcenter=False): + self.ensure_one() + if not self.workcenter_id: + return False + qty_production = self.production_id.product_uom_id._compute_quantity(self.qty_production, self.production_id.product_id.uom_id) + cycle_number = float_round(qty_production / self.workcenter_id.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.time_start - self.workcenter_id.time_stop) * self.workcenter_id.time_efficiency / (100.0 * cycle_number) + if duration_expected_working < 0: + duration_expected_working = 0 + return alternative_workcenter.time_start + alternative_workcenter.time_stop + cycle_number * duration_expected_working * 100.0 / alternative_workcenter.time_efficiency + time_cycle = self.operation_id and self.operation_id.time_cycle or 60.0 + return self.workcenter_id.time_start + self.workcenter_id.time_stop + cycle_number * time_cycle * 100.0 / self.workcenter_id.time_efficiency + def _get_conflicted_workorder_ids(self): """Get conlicted workorder(s) with self. diff --git a/addons/mrp/models/stock_rule.py b/addons/mrp/models/stock_rule.py index dc64ec7dce6..64427e58d76 100644 --- a/addons/mrp/models/stock_rule.py +++ b/addons/mrp/models/stock_rule.py @@ -55,6 +55,7 @@ class StockRule(models.Model): # 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()) + productions._create_workorder() productions.filtered(lambda p: p.move_raw_ids).action_confirm() for production in productions: diff --git a/addons/mrp/static/src/js/mrp_workorder_popover.js b/addons/mrp/static/src/js/mrp_workorder_popover.js index 6d515aa2340..7e392c09c7a 100644 --- a/addons/mrp/static/src/js/mrp_workorder_popover.js +++ b/addons/mrp/static/src/js/mrp_workorder_popover.js @@ -24,6 +24,9 @@ var MrpWorkorderPopover = PopoverWidget.extend({ _render: function () { this._super.apply(this, arguments); + if (! this.$popover) { + return; + } var self = this; this.$popover.find('.action_replan_button').click(function (e) { self._onReplanClick(e); diff --git a/addons/mrp/tests/test_backorder.py b/addons/mrp/tests/test_backorder.py index 3dedc1528c0..0c3361781ad 100644 --- a/addons/mrp/tests/test_backorder.py +++ b/addons/mrp/tests/test_backorder.py @@ -347,19 +347,14 @@ class TestMrpWorkorderBackorder(SavepointCase): self.assertEqual(workorder1.raw_workorder_line_ids.product_id, self.compfinished1) self.assertEqual(workorder1.raw_workorder_line_ids.qty_to_consume, 2) - #mrp_production_workorder_form_view_inherit workorder1.button_start() - with Form(workorder1) as w: - w.qty_producing = 1 - workorder1.record_production() + workorder1.button_finish() self.assertEqual(workorder2.raw_workorder_line_ids.product_id, self.compfinished2) self.assertEqual(workorder2.raw_workorder_line_ids.qty_to_consume, 2) workorder2.button_start() - with Form(workorder2) as w: - w.qty_producing = 1 - workorder2.record_production() + workorder2.button_finish() action = mo.with_context(debug=True).button_mark_done() backorder = Form(self.env['mrp.production.backorder'].with_context(**action['context'])) diff --git a/addons/mrp/tests/test_cancel_mo.py b/addons/mrp/tests/test_cancel_mo.py index 1bca25b3664..20f0e9457c7 100644 --- a/addons/mrp/tests/test_cancel_mo.py +++ b/addons/mrp/tests/test_cancel_mo.py @@ -82,13 +82,32 @@ class TestMrpCancelMO(TestMrpCommon): done and the WO must be cancelled. """ # Create MO - mo_data = self.generate_mo(consumption='strict') - manufacturing_order = mo_data[0] - bom = mo_data[1] - bom.write({ + product_to_build = self.env['product.product'].create({ + 'name': 'Young Tom', + 'type': 'product', + }) + product_to_use_1 = self.env['product.product'].create({ + 'name': 'Botox', + 'type': 'product', + }) + product_to_use_2 = self.env['product.product'].create({ + 'name': 'Old Tom', + 'type': 'product', + }) + bom = self.env['mrp.bom'].create({ + 'product_id': product_to_build.id, + 'product_tmpl_id': product_to_build.product_tmpl_id.id, + 'product_uom_id': self.uom_unit.id, + 'product_qty': 1.0, + 'consumption': 'strict', + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': product_to_use_2.id, 'product_qty': 4}), + (0, 0, {'product_id': product_to_use_1.id, 'product_qty': 1}) + ], 'operation_ids': [ (0, 0, {'name': 'Gift Wrap Maching', 'workcenter_id': self.workcenter_1.id, 'time_cycle': 15, 'sequence': 1}), - ], + ] }) mo_form = Form(self.env['mrp.production']) mo_form.product_id = product_to_build @@ -96,9 +115,6 @@ class TestMrpCancelMO(TestMrpCommon): mo_form.product_qty = 5.0 manufacturing_order = mo_form.save() manufacturing_order.action_confirm() - mo_form = Form(manufacturing_order) - mo_form.qty_producing = 2 - manufacturing_order = mo_form.save() manufacturing_order.button_plan() workorder = manufacturing_order.workorder_ids diff --git a/addons/mrp/tests/test_workorder_operation.py b/addons/mrp/tests/test_workorder_operation.py index b8d71fd4bf7..91bacd60d47 100644 --- a/addons/mrp/tests/test_workorder_operation.py +++ b/addons/mrp/tests/test_workorder_operation.py @@ -995,6 +995,53 @@ class TestWorkOrderProcess(TestWorkOrderProcessCommon): self.assertEqual(production_table.workorder_ids[0].qty_producing, 2, "Quantity to produce not updated") + def test_expected_duration_workorder(self): + """ Test that the expected_duration is editable and change when other + workcenter is set or product quantity to produce is increased + Also this expected duration need to be recalculate correctly during the planning + if it used a alternative workcenter + """ + planned_date = datetime(2023, 5, 15, 9, 0) + self.mrp_workcenter_3.write({'capacity': 1, 'time_start': 0, 'time_stop': 2, 'time_efficiency': 50}) + self.workcenter_1.write({'capacity': 2, 'time_start': 2, 'time_stop': 1, 'time_efficiency': 80, 'alternative_workcenter_ids': [(6, 0, [self.mrp_workcenter_3.id])]}) + self.planning_bom.operation_ids[0].write({'time_cycle_manual': 10}) + mo_form = Form(self.env['mrp.production']) + mo_form.bom_id = self.planning_bom + mo_form.date_planned_start = planned_date + mo = mo_form.save() + + # TEST Default/onchange duration_expected + self.assertEqual(len(mo.workorder_ids), 1) + self.assertEqual(mo.workorder_ids.workcenter_id, self.workcenter_1, "wrong workcenter") + # time_cycle = 10, product_qty = 4.0, capacity = 2, time_start = 2, time_stop = 1, time_efficiency = 80% + # => 2 + 1 + (4 / 2 * 10) * (100 / 80) = 28.0 + self.assertEqual(mo.workorder_ids.duration_expected, 28.0, "wrong duration_expected") + mo_form = Form(mo) + with mo_form.workorder_ids.edit(0) as wo_form: + wo_form.workcenter_id = self.mrp_workcenter_3 + mo = mo_form.save() + # time_cycle = 10, product_qty = 4.0, capacity = 1, time_start = 0, time_stop = 2, time_efficiency = 50% + # => 0 + 2 + (4 / 1 * 10) * (100 / 50) = 82.0 + self.assertEqual(mo.workorder_ids.duration_expected, 82.0, "wrong duration_expected") + + # TEST Manual change of duration_expected + with mo_form.workorder_ids.edit(0) as wo_form: + wo_form.workcenter_id = self.workcenter_1 + self.assertEqual(wo_form.duration_expected, 28.0, "wrong duration_expected") + wo_form.duration_expected = 40.0 + mo = mo_form.save() + + self.assertEqual(mo.workorder_ids.duration_expected, 40.0, "wrong duration_expected") + mo = mo_form.save() + mo.action_confirm() + mo.button_plan() + self.assertEqual(mo.workorder_ids.duration_expected, 40.0, "duration_expected doesn't reset") + + self.assertAlmostEqual(mo.date_planned_start, mo.workorder_ids.date_planned_start, delta=timedelta(seconds=10)) + self.assertAlmostEqual(mo.date_planned_finished, mo.workorder_ids.date_planned_finished, delta=timedelta(seconds=10)) + self.assertAlmostEqual(mo.date_planned_start, planned_date, delta=timedelta(seconds=10)) + self.assertAlmostEqual(mo.date_planned_finished, planned_date + timedelta(minutes=40), delta=timedelta(seconds=10)) + def test_planning_0(self): """ Test alternative conditions 1. alternative relation is directionnal @@ -1253,7 +1300,7 @@ class TestWorkOrderProcess(TestWorkOrderProcessCommon): self.assertEqual(bool(leave.exists()), True) # Unplans the MO and checks the workorder and its leave no more exist. mo.button_unplan() - self.assertEqual(bool(mo.workorder_ids.exists()), False) + self.assertEqual(bool(mo.workorder_ids.exists()), True) self.assertEqual(bool(leave.exists()), False) # Plans (again) the MO and checks the date is still the same. mo.button_plan() diff --git a/addons/mrp/views/mrp_bom_views.xml b/addons/mrp/views/mrp_bom_views.xml index c228119344b..b44e8a931e2 100644 --- a/addons/mrp/views/mrp_bom_views.xml +++ b/addons/mrp/views/mrp_bom_views.xml @@ -96,6 +96,15 @@ + + + - - -
diff --git a/addons/mrp/views/mrp_production_views.xml b/addons/mrp/views/mrp_production_views.xml index 2f6fc4cbd22..64f4236d810 100644 --- a/addons/mrp/views/mrp_production_views.xml +++ b/addons/mrp/views/mrp_production_views.xml @@ -63,8 +63,8 @@
- - - - - - - - - + + + + + + + + - - @@ -165,7 +130,7 @@ - +
@@ -186,44 +151,6 @@ - - - - - - - - - - -

Components

- - - - - - - - - - - - - -

Finished Products

- - - - - - - - - - - - -
@@ -485,6 +412,15 @@ + + Work Orders + ir.actions.act_window + mrp.workorder + form + new + + + Work Orders ir.actions.act_window