diff --git a/addons/mrp/__manifest__.py b/addons/mrp/__manifest__.py index 55d09e1c1de..7a6476f7818 100644 --- a/addons/mrp/__manifest__.py +++ b/addons/mrp/__manifest__.py @@ -24,6 +24,7 @@ 'wizard/mrp_consumption_warning_views.xml', 'wizard/mrp_immediate_production_views.xml', 'wizard/stock_assign_serial_numbers.xml', + 'wizard/mrp_production_split.xml', 'views/mrp_views_menus.xml', 'views/stock_move_views.xml', 'views/mrp_workorder_views.xml', diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 7c2c042838a..d8536299698 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -10,7 +10,7 @@ from ast import literal_eval from collections import defaultdict from dateutil.relativedelta import relativedelta -from odoo import api, fields, models, _ +from odoo import api, fields, models, _, Command from odoo.exceptions import UserError, ValidationError from odoo.tools import float_compare, float_round, float_is_zero, format_datetime from odoo.tools.misc import OrderedSet, format_date @@ -381,7 +381,7 @@ class MrpProduction(models.Model): for production in self: production.confirm_cancel = productions_with_done_move.get(production.id, False) - @api.depends('procurement_group_id') + @api.depends('procurement_group_id', 'procurement_group_id.stock_move_ids.group_id') def _compute_picking_ids(self): for order in self: order.picking_ids = self.env['stock.picking'].search([ @@ -539,7 +539,7 @@ class MrpProduction(models.Model): and order.state not in {'cancel', 'draft'} ) - @api.depends('state','move_raw_ids') + @api.depends('state', 'move_raw_ids') def _compute_show_lot_ids(self): for order in self: order.show_lot_ids = order.state != 'draft' and any(m.product_id.tracking == 'serial' for m in order.move_raw_ids) @@ -1406,7 +1406,6 @@ class MrpProduction(models.Model): self.workorder_ids.filtered(lambda x: x.state not in ['done', 'cancel']).action_cancel() finish_moves = self.move_finished_ids.filtered(lambda x: x.state not in ('done', 'cancel')) raw_moves = self.move_raw_ids.filtered(lambda x: x.state not in ('done', 'cancel')) - (finish_moves | raw_moves)._action_cancel() picking_ids = self.picking_ids.filtered(lambda x: x.state not in ('done', 'cancel')) picking_ids.action_cancel() @@ -1482,7 +1481,7 @@ class MrpProduction(models.Model): 'move_finished_ids': None, 'lot_producing_id': False, 'origin': self.origin, - 'state': 'confirmed', + 'state': 'draft' if self.state == 'draft' else 'confirmed', 'date_deadline': self.date_deadline, 'orderpoint_id': self.orderpoint_id.id, } @@ -1928,6 +1927,73 @@ class MrpProduction(models.Model): } return action + def action_split(self): + self._pre_action_split_merge_hook(split=True) + if len(self) > 1: + productions = [Command.create({'production_id': production.id}) for production in self] + # Wizard need a real id to have buttons enable in the view + wizard = self.env['mrp.production.split.multi'].create({'production_ids': productions}) + action = self.env['ir.actions.actions']._for_xml_id('mrp.action_mrp_production_split_multi') + action['res_id'] = wizard.id + return action + else: + action = self.env['ir.actions.actions']._for_xml_id('mrp.action_mrp_production_split') + action['context'] = { + 'default_production_id': self.id, + } + return action + + def action_merge(self): + self._pre_action_split_merge_hook(merge=True) + products = set([(production.product_id, production.bom_id) for production in self]) + product_id, bom_id = products.pop() + users = set([production.user_id for production in self]) + if len(users) == 1: + user_id = users.pop() + else: + user_id = self.env.user + + origs = {} + for move in self.move_raw_ids: + origs.setdefault(move.bom_line_id.id, []).extend(move.move_orig_ids.ids) + dests = {} + for move in self.move_finished_ids: + dests.setdefault(move.byproduct_id.id, []).extend(move.move_dest_ids.ids) + + 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(), + '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: + move.move_orig_ids = [Command.set(origs[move.bom_line_id.id])] + for move in production.move_finished_ids: + move.move_dest_ids = [Command.set(dests[move.byproduct_id.id])] + production.move_dest_ids = [Command.set(sum(list(dests.values()), []))] + + self.procurement_group_id.stock_move_ids.group_id = production.procurement_group_id + + if 'confirmed' in self.mapped('state'): + production.action_confirm() + + self.with_context(skip_activity=True)._action_cancel() + + return { + 'type': 'ir.actions.act_window', + 'res_model': 'mrp.production', + 'view_mode': 'form', + 'res_id': production.id, + 'target': 'main', + } + @api.model def _prepare_procurement_group_vals(self, values): return {'name': values['name']} @@ -2033,3 +2099,30 @@ class MrpProduction(models.Model): ) and float_is_zero(production.qty_producing, precision_digits=pd): immediate_productions |= production return immediate_productions + + def _pre_action_split_merge_hook(self, merge=False, split=False): + if not merge and not split: + return True + ope_str = merge and 'merge' or 'split' + if any(production.state not in ('draft', 'confirmed') for production in self): + raise UserError(_("Only manufacturing orders in either a draft or confirmed state can be %s.", ope_str)) + if any(not production.bom_id for production in self): + raise UserError(_("Only manufacturing orders with a Bill of Materials can be %s.", ope_str)) + if split: + return True + + if len(self) < 2: + raise UserError(_("You need at least two production orders to merge them.")) + products = set([(production.product_id, production.bom_id) for production in self]) + if len(products) > 1: + raise UserError(_('You can only merge manufacturing orders of identical products with same BoM.')) + additional_raw_ids = self.mapped("move_raw_ids").filtered(lambda move: not move.bom_line_id) + additional_byproduct_ids = self.mapped('move_byproduct_ids').filtered(lambda move: not move.byproduct_id) + if additional_raw_ids or additional_byproduct_ids: + raise UserError(_("You can only merge manufacturing orders with no additional components or by-products.")) + if len(set(self.mapped('state'))) > 1: + raise UserError(_("You can only merge manufacturing with the same state.")) + if len(set(self.mapped('picking_type_id'))) > 1: + raise UserError(_('You can only merge manufacturing with the same operation type')) + # TODO explode and check no quantity has been edited + return True diff --git a/addons/mrp/security/ir.model.access.csv b/addons/mrp/security/ir.model.access.csv index 3b0519badb6..ae83136a1d6 100644 --- a/addons/mrp/security/ir.model.access.csv +++ b/addons/mrp/security/ir.model.access.csv @@ -58,3 +58,6 @@ access_mrp_immediate_production,access.mrp.immediate.production,model_mrp_immedi access_mrp_immediate_production_line,access.mrp.immediate.production.line,model_mrp_immediate_production_line,mrp.group_mrp_user,1,1,1,0 access_mrp_workcenter_tag_group_user,access.mrp.workcenter.tag,model_mrp_workcenter_tag,mrp.group_mrp_user,1,0,0,0 access_mrp_workcenter_tag_manager,access.mrp.workcenter.tag,model_mrp_workcenter_tag,mrp.group_mrp_manager,1,1,1,1 +access_mrp_production_split_multi,access.mrp.production.split.multi,model_mrp_production_split_multi,mrp.group_mrp_user,1,1,1,0 +access_mrp_production_split,access.mrp.production.split,model_mrp_production_split,mrp.group_mrp_user,1,1,1,0 +access_mrp_production_split_line,access.mrp.production.split.line,model_mrp_production_split_line,mrp.group_mrp_user,1,1,1,1 diff --git a/addons/mrp/tests/test_backorder.py b/addons/mrp/tests/test_backorder.py index cefb374b1fc..12756ea6cff 100644 --- a/addons/mrp/tests/test_backorder.py +++ b/addons/mrp/tests/test_backorder.py @@ -356,6 +356,41 @@ class TestMrpProductionBackorder(TestMrpCommon): self.assertEqual(production.name.split('-')[0], backorder_ids.name.split('-')[0]) self.assertEqual(int(production.name.split('-')[1]) + 1, int(backorder_ids.name.split('-')[1])) + def test_split_merge(self): + # Change 'Units' rounding to 1 (integer only quantities) + self.uom_unit.rounding = 1 + # Create a mo for 10 products + mo, _, _, p1, p2 = self.generate_mo(qty_final=10) + # Split in 3 parts + action = mo.action_split() + wizard = Form(self.env[action['res_model']].with_context(action['context'])) + wizard.counter = 3 + action = wizard.save().action_split() + # Should have 3 mos + self.assertEqual(len(mo.procurement_group_id.mrp_production_ids), 3) + mo1 = mo.procurement_group_id.mrp_production_ids[0] + mo2 = mo.procurement_group_id.mrp_production_ids[1] + mo3 = mo.procurement_group_id.mrp_production_ids[2] + # Check quantities + self.assertEqual(mo1.product_qty, 3) + self.assertEqual(mo2.product_qty, 3) + self.assertEqual(mo3.product_qty, 4) + # Check raw movew quantities + self.assertEqual(mo1.move_raw_ids.filtered(lambda m: m.product_id == p1).product_qty, 12) + self.assertEqual(mo2.move_raw_ids.filtered(lambda m: m.product_id == p1).product_qty, 12) + self.assertEqual(mo3.move_raw_ids.filtered(lambda m: m.product_id == p1).product_qty, 16) + self.assertEqual(mo1.move_raw_ids.filtered(lambda m: m.product_id == p2).product_qty, 3) + self.assertEqual(mo2.move_raw_ids.filtered(lambda m: m.product_id == p2).product_qty, 3) + self.assertEqual(mo3.move_raw_ids.filtered(lambda m: m.product_id == p2).product_qty, 4) + + # Merge them back + expected_origin = ",".join([mo1.name, mo2.name, mo3.name]) + action = (mo1 + mo2 + mo3).action_merge() + mo = self.env[action['res_model']].browse(action['res_id']) + # Check origin & initial quantity + self.assertEqual(mo.origin, expected_origin) + self.assertEqual(mo.product_qty, 10) + class TestMrpWorkorderBackorder(TransactionCase): @classmethod diff --git a/addons/mrp/views/mrp_production_views.xml b/addons/mrp/views/mrp_production_views.xml index f5bc9847ec0..1f234c66554 100644 --- a/addons/mrp/views/mrp_production_views.xml +++ b/addons/mrp/views/mrp_production_views.xml @@ -47,14 +47,22 @@ - - Mrp: Plan Production Orders + + Split + + + list,form + code + action = records.action_split() + + + + Merge list - code - records.button_plan() + action = records.action_merge() diff --git a/addons/mrp/wizard/__init__.py b/addons/mrp/wizard/__init__.py index 1ba2ffdb12f..c4a6c511345 100644 --- a/addons/mrp/wizard/__init__.py +++ b/addons/mrp/wizard/__init__.py @@ -7,3 +7,4 @@ from . import mrp_production_backorder from . import mrp_consumption_warning from . import mrp_immediate_production from . import stock_assign_serial_numbers +from . import mrp_production_split diff --git a/addons/mrp/wizard/mrp_production_split.py b/addons/mrp/wizard/mrp_production_split.py new file mode 100644 index 00000000000..6d60a5521cf --- /dev/null +++ b/addons/mrp/wizard/mrp_production_split.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, Command +from odoo.tools import float_round, float_compare + + +class MrpProductionSplitMulti(models.TransientModel): + _name = 'mrp.production.split.multi' + _description = "Wizard to Split Multiple Productions" + + production_ids = fields.One2many('mrp.production.split', 'production_split_multi_id', 'Productions To Split') + + +class MrpProductionSplit(models.TransientModel): + _name = 'mrp.production.split' + _description = "Wizard to Split a Production" + + production_split_multi_id = fields.Many2one('mrp.production.split.multi', 'Split Productions') + production_id = fields.Many2one('mrp.production', 'Manufacturing Order', readonly=True) + product_id = fields.Many2one(related='production_id.product_id') + product_qty = fields.Float(related='production_id.product_qty') + product_uom_id = fields.Many2one(related='production_id.product_uom_id') + counter = fields.Integer( + "Split #", default=0, compute="_compute_counter", + store=True, readonly=False) + production_detailed_vals_ids = fields.One2many( + 'mrp.production.split.line', 'mrp_production_split_id', + 'Split Details', compute="_compute_details", store=True, readonly=False) + valid_details = fields.Boolean("Valid", compute="_compute_valid_details") + + @api.depends('production_detailed_vals_ids') + def _compute_counter(self): + for wizard in self: + wizard.counter = len(wizard.production_detailed_vals_ids) + + @api.depends('counter') + def _compute_details(self): + for wizard in self: + commands = [Command.clear()] + if wizard.counter < 1 or not wizard.production_id: + wizard.production_detailed_vals_ids = commands + continue + quantity = float_round(wizard.product_qty / wizard.counter, precision_rounding=wizard.product_uom_id.rounding) + remaining_quantity = wizard.product_qty + for _ in range(wizard.counter - 1): + commands.append(Command.create({ + 'quantity': quantity, + 'user_id': wizard.production_id.user_id, + 'date': wizard.production_id.date_planned_start, + })) + remaining_quantity = float_round(remaining_quantity - quantity, precision_rounding=wizard.product_uom_id.rounding) + commands.append(Command.create({ + 'quantity': remaining_quantity, + 'user_id': wizard.production_id.user_id, + 'date': wizard.production_id.date_planned_start, + })) + wizard.production_detailed_vals_ids = commands + + @api.depends('production_detailed_vals_ids') + def _compute_valid_details(self): + self.valid_details = False + for wizard in self: + if wizard.production_detailed_vals_ids: + wizard.valid_details = float_compare(wizard.product_qty, sum(wizard.production_detailed_vals_ids.mapped('quantity')), precision_rounding=wizard.product_uom_id.rounding) == 0 + + def action_split(self): + productions = self.production_id._split_productions({self.production_id: [detail.quantity for detail in self.production_detailed_vals_ids]}) + for production, detail in zip(productions, self.production_detailed_vals_ids): + production.user_id = detail.user_id + production.date_planned_start = detail.date + if self.production_split_multi_id: + saved_production_split_multi_id = self.production_split_multi_id.id + self.production_split_multi_id.production_ids = [Command.unlink(self.id)] + action = self.env['ir.actions.actions']._for_xml_id('mrp.action_mrp_production_split_multi') + action['res_id'] = saved_production_split_multi_id + return action + + def action_prepare_split(self): + action = self.env['ir.actions.actions']._for_xml_id('mrp.action_mrp_production_split') + action['res_id'] = self.id + return action + + def action_return_to_list(self): + self.production_detailed_vals_ids = [Command.clear()] + self.counter = 0 + action = self.env['ir.actions.actions']._for_xml_id('mrp.action_mrp_production_split_multi') + action['res_id'] = self.production_split_multi_id.id + return action + + +class MrpProductionSplitLine(models.TransientModel): + _name = 'mrp.production.split.line' + _description = "Split Production Detail" + + mrp_production_split_id = fields.Many2one( + 'mrp.production.split', 'Split Production', required=True, ondelete="cascade") + quantity = fields.Float('Quantity To Produce', digits='Product Unit of Measure', required=True) + user_id = fields.Many2one( + 'res.users', 'Responsible', required=True, + domain=lambda self: [('groups_id', 'in', self.env.ref('mrp.group_mrp_user').id)]) + date = fields.Datetime('Schedule Date') diff --git a/addons/mrp/wizard/mrp_production_split.xml b/addons/mrp/wizard/mrp_production_split.xml new file mode 100644 index 00000000000..6603b8e0333 --- /dev/null +++ b/addons/mrp/wizard/mrp_production_split.xml @@ -0,0 +1,80 @@ + + + + + + mrp.production.split.multi.form + mrp.production.split.multi + form + +
+ + + + + + +