From d7716f071d5da2b3203762cd46cb32f5637be5d7 Mon Sep 17 00:00:00 2001 From: Arnold Moyaux Date: Fri, 22 Apr 2022 08:07:58 +0000 Subject: [PATCH] [REF] mrp: onchange to compute This task will: - Clean the code (e.g. the production's moves are now created directly) - Have the same behavior when creating object in the UI or directly in backend It's mainly on the `mrp.production` object and most of the computed stored field are used as default value or act like an onchange on draft production. Once the production order is validated, it's only modify by direct write Task-2709753 closes odoo/odoo#83576 Related: odoo/enterprise#24405 Signed-off-by: Arnold Moyaux --- addons/mrp/data/mrp_demo.xml | 35 -- addons/mrp/models/mrp_production.py | 532 +++++++++--------- addons/mrp/models/mrp_workorder.py | 15 +- addons/mrp/models/stock_rule.py | 7 - addons/mrp/populate/mrp.py | 11 - addons/mrp/tests/test_backorder.py | 7 +- addons/mrp/tests/test_order.py | 12 +- addons/mrp/tests/test_procurement.py | 6 +- addons/mrp/views/mrp_production_views.xml | 2 +- addons/mrp_account/data/mrp_account_demo.xml | 8 - .../tests/test_analytic_account.py | 2 + .../tests/test_subcontracting.py | 1 - 12 files changed, 284 insertions(+), 354 deletions(-) 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 @@