From e831903612efbb21698d911c5b156ecdcdcae531 Mon Sep 17 00:00:00 2001 From: svs-odoo Date: Tue, 17 Jan 2023 16:33:38 +0000 Subject: [PATCH] [IMP] mrp: generate BoM from a MO In a Manufacturing Order, if the user manufactures a product without any BoM, they can generate a new BoM who will take the information from the MO. So, the new BoM will have a BoM line for each MO's component, an operation for each MO's work order, and the By-Product lines will be copied too. task-3089368 Part-of: odoo/odoo#110227 --- addons/mrp/models/mrp_bom.py | 22 +- addons/mrp/models/mrp_production.py | 159 +++++++++++++++ addons/mrp/models/mrp_workorder.py | 12 ++ addons/mrp/tests/test_bom.py | 192 +++++++++++++++++- addons/mrp/views/mrp_bom_views.xml | 10 +- addons/mrp/views/mrp_production_views.xml | 8 +- addons/mrp/views/mrp_routing_views.xml | 3 +- .../views/mrp_production_views.xml | 3 +- 8 files changed, 395 insertions(+), 14 deletions(-) diff --git a/addons/mrp/models/mrp_bom.py b/addons/mrp/models/mrp_bom.py index 8fd0c5164a3..c0271d2a64c 100644 --- a/addons/mrp/models/mrp_bom.py +++ b/addons/mrp/models/mrp_bom.py @@ -3,8 +3,9 @@ from odoo import api, fields, models, _, Command from odoo.exceptions import UserError, ValidationError -from odoo.osv.expression import AND, NEGATIVE_TERM_OPERATORS +from odoo.osv.expression import AND from odoo.tools import float_round +from odoo.tools.misc import clean_context from collections import defaultdict @@ -168,7 +169,10 @@ class MrpBom(models.Model): @api.onchange('product_tmpl_id') def onchange_product_tmpl_id(self): if self.product_tmpl_id: - self.product_uom_id = self.product_tmpl_id.uom_id.id + default_uom_id = self.env.context.get('default_product_uom_id') + # Avoids updating the BoM's UoM in case a specific UoM was passed through as a default value. + if self.product_uom_id.category_id != self.product_tmpl_id.uom_id.category_id or self.product_uom_id.id != default_uom_id: + self.product_uom_id = self.product_tmpl_id.uom_id.id if self.product_id.product_tmpl_id != self.product_tmpl_id: self.product_id = False self.bom_line_ids.bom_product_template_attribute_value_ids = False @@ -181,8 +185,18 @@ class MrpBom(models.Model): number_of_bom_of_this_product = self.env['mrp.bom'].search_count(domain) if number_of_bom_of_this_product: # add a reference to the bom if there is already a bom for this product self.code = _("%s (new) %s", self.product_tmpl_id.name, number_of_bom_of_this_product) - else: - self.code = False + + @api.model_create_multi + def create(self, vals_list): + res = super().create(vals_list) + # Checks if the BoM was created from a Manufacturing Order (through Generate BoM action). + parent_production_id = self.env.context.get('parent_production_id') + if parent_production_id: # In this case, assign the newly created BoM to the MO. + # Clean context to avoid parasitic default values. + self.env.context = clean_context(self.env.context) + production = self.env['mrp.production'].browse(parent_production_id) + production._link_bom(res[0]) + return res def copy(self, default=None): res = super().copy(default) diff --git a/addons/mrp/models/mrp_production.py b/addons/mrp/models/mrp_production.py index 71c0b7cda81..b5ea7eb4732 100644 --- a/addons/mrp/models/mrp_production.py +++ b/addons/mrp/models/mrp_production.py @@ -897,6 +897,31 @@ class MrpProduction(models.Model): copied_workorder.blocked_by_workorder_ids = dependencies return res + def action_generate_bom(self): + """ Generates a new Bill of Material based on the Manufacturing Order's product, components, + workorders and by-products, and assigns it to the MO. Returns a new BoM's form view action. + """ + self.ensure_one() + action = self.env['ir.actions.act_window']._for_xml_id('mrp.mrp_bom_form_action') + action['view_mode'] = 'form' + action['views'] = [(False, 'form')] + action['target'] = 'new' + + bom_lines_vals, byproduct_vals, operations_vals = self._get_bom_values() + action['context'] = { + 'default_bom_line_ids': bom_lines_vals, + 'default_byproduct_ids': byproduct_vals, + 'default_code': _("New BoM from %(mo_name)s", mo_name=self.display_name), + 'default_company_id': self.company_id.id, + 'default_operation_ids': operations_vals, + 'default_product_id': self.product_id.id, + 'default_product_qty': self.product_qty, + 'default_product_tmpl_id': self.product_id.product_tmpl_id.id, + 'default_product_uom_id': self.product_uom_id.id, + 'parent_production_id': self.id, # Used to assign the new BoM to the current MO. + } + return action + 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). @@ -931,6 +956,30 @@ class MrpProduction(models.Model): action['context']['warehouse'] = warehouse.id return action + def _get_bom_values(self, ratio=1): + """ Returns the BoM lines, by-products and operations values needed to + create a new BoM from this Manufacturing Order. + :return: A tuple containing the BoM lines, by-products and operations values, in this order + :rtype: tuple(dict, dict, dict) + """ + self.ensure_one() + # BoM lines values. + bom_lines_values = [Command.create({ + 'product_id': move_raw.product_id.id, + 'product_qty': (move_raw.quantity_done or move_raw.product_uom_qty) * ratio, + 'product_uom_id': move_raw.product_uom.id, + }) for move_raw in self.move_raw_ids] + # By-Product lines values. + byproduct_values = [Command.create({ + 'cost_share': move_byproduct.cost_share, + 'product_id': move_byproduct.product_id.id, + 'product_qty': (move_byproduct.quantity_done or move_byproduct.product_uom_qty) * ratio, + 'product_uom_id': move_byproduct.product_uom.id, + }) for move_byproduct in self.move_byproduct_ids] + # Operations values. + operations_values = [Command.create(wo._get_operation_values()) for wo in self.workorder_ids] + return (bom_lines_values, byproduct_values, operations_values) + @api.model def _get_default_picking_type_id(self, company_id): return self.env['stock.picking.type'].search([ @@ -2093,6 +2142,109 @@ class MrpProduction(models.Model): def _has_workorders(self): return self.workorder_ids + def _link_bom(self, bom): + """ Links the given BoM to the MO. Assigns BoM's lines, by-products and operations + to the corresponding MO's components, by-products and workorders. + This method assumes the production isn't linked to any BoM yet. + """ + self.ensure_one() + moves_to_unlink = self.env['stock.move'] + workorders_to_unlink = self.env['mrp.workorder'] + # For draft MO, all the work will be done by compute methods. + # For cancelled and done MO, we don't want to do anything more than assinging the BoM. + if self.state == 'draft' and self.bom_id == bom: + # Empties `bom_id` field so when the BoM is reassigns to this field, depending computes + # will be triggered (doesn't happen if the field's value doesn't change). + self.bom_id = False + if self.state in ['cancel', 'done', 'draft']: + if self.state == 'draft': + # Don't straight delete the moves/workorders to avoid to cancel the MO, those will + # be deleted once the BoM is assigned (and thus after new moves/WO were created). + moves_to_unlink = self.move_raw_ids + workorders_to_unlink = self.workorder_ids + self.bom_id = bom + moves_to_unlink.unlink() + workorders_to_unlink.unlink() + return + + def operation_key_values(record): + return tuple(record[key] for key in ('company_id', 'name', 'workcenter_id')) + + def filter_by_attributes(record): + product_attribute_ids = self.product_id.product_template_attribute_value_ids.ids + return not record.bom_product_template_attribute_value_ids or\ + any(att_val.id in product_attribute_ids for att_val in record.bom_product_template_attribute_value_ids) + + ratio = self._get_ratio_between_mo_and_bom_quantities(bom) + bom_lines = list(bom.bom_line_ids.filtered(filter_by_attributes)) + bom_byproducts = list(bom.byproduct_ids.filtered(filter_by_attributes)) + operations = list(bom.operation_ids.filtered(filter_by_attributes)) + + # Compares the BoM's lines to the MO's components. + for move_raw in self.move_raw_ids: + for i, bom_line in enumerate(bom_lines): + if bom_line.product_id == move_raw.product_id: + move_raw.bom_line_id = bom_line + move_raw.product_uom_qty = bom_line.product_qty / ratio + move_raw.product_uom = bom_line.product_uom_id + del bom_lines[i] + break + # Creates a raw moves for each remaining BoM's lines. + raw_moves_values = [] + for bom_line in bom_lines: + raw_move_vals = self._get_move_raw_values( + bom_line.product_id, + bom_line.product_qty / ratio, + bom_line.product_uom_id, + bom_line=bom_line + ) + raw_moves_values.append(raw_move_vals) + self.move_raw_ids += self.env['stock.move'].create(raw_moves_values) + + # Compares the BoM's and the MO's by-products. + for move_byproduct in self.move_byproduct_ids: + for i, bom_byproduct in enumerate(bom_byproducts): + if bom_byproduct.product_id == move_byproduct.product_id: + move_byproduct.byproduct_id = bom_byproduct + move_byproduct.cost_share = bom_byproduct.cost_share + move_byproduct.product_uom_qty = bom_byproduct.product_qty / ratio + move_byproduct.product_uom = bom_byproduct.product_uom_id + del bom_byproducts[i] + break + # For each remaining BoM's by-product, creates a move finished. + byproduct_values = [] + for bom_byproduct in bom_byproducts: + qty = bom_byproduct.product_qty / ratio + move_byproduct_vals = self._get_move_finished_values( + bom_byproduct.product_id.id, qty, bom_byproduct.product_uom_id.id, + bom_byproduct.operation_id.id, bom_byproduct.id, bom_byproduct.cost_share + ) + byproduct_values.append(move_byproduct_vals) + self.move_finished_ids += self.env['stock.move'].create(byproduct_values) + + # Compares the BoM's operations to the MO's workoders. + for workorder in self.workorder_ids: + for i, operation in enumerate(operations): + if operation_key_values(operation) == operation_key_values(workorder): + workorder.operation_id = operation + del operations[i] + break + # Creates a workorder for each remaining operation. + workorders_values = [] + for operation in operations: + workorder_vals = { + 'name': operation.name, + 'operation_id': operation.id, + 'product_uom_id': self.product_uom_id.id, + 'production_id': self.id, + 'state': 'pending', + 'workcenter_id': operation.workcenter_id.id, + } + workorders_values.append(workorder_vals) + self.workorder_ids += self.env['mrp.workorder'].create(workorders_values) + + self.bom_id = bom + @api.model def _prepare_procurement_group_vals(self, values): return {'name': values['name']} @@ -2101,6 +2253,13 @@ class MrpProduction(models.Model): self.ensure_one() return max(self.product_qty - self.qty_producing, 0) + def _get_ratio_between_mo_and_bom_quantities(self, bom): + self.ensure_one() + bom_product_uom = (bom.product_id or bom.product_tmpl_id).uom_id + bom_qty = bom.product_uom_id._compute_quantity(bom.product_qty, bom_product_uom) + ratio = bom_qty / self.product_uom_qty + return ratio + def _check_sn_uniqueness(self): """ Alert the user if the serial number as already been consumed/produced """ if self.product_tracking == 'serial' and self.lot_producing_id: diff --git a/addons/mrp/models/mrp_workorder.py b/addons/mrp/models/mrp_workorder.py index 6ec91f49709..32561266444 100644 --- a/addons/mrp/models/mrp_workorder.py +++ b/addons/mrp/models/mrp_workorder.py @@ -784,6 +784,18 @@ class MrpWorkorder(models.Model): res[wo1].append(wo2) return res + def _get_operation_values(self): + self.ensure_one() + ratio = 1 / self.qty_production + if self.operation_id.bom_id: + ratio = self.production_id._get_ratio_between_mo_and_bom_quantities(self.operation_id.bom_id) + return { + 'company_id': self.company_id.id, + 'name': self.name, + 'time_cycle_manual': self.duration_expected * ratio, + 'workcenter_id': self.workcenter_id.id, + } + def _prepare_timeline_vals(self, duration, date_start, date_end=False): # Need a loss in case of the real time exceeding the expected if not self.duration_expected or duration <= self.duration_expected: diff --git a/addons/mrp/tests/test_bom.py b/addons/mrp/tests/test_bom.py index da91a35f234..725c581a5c8 100644 --- a/addons/mrp/tests/test_bom.py +++ b/addons/mrp/tests/test_bom.py @@ -693,7 +693,6 @@ class TestBoM(TestMrpCommon): # 5 min 'Prepare biscuits' + 3 min 'Prepare butter' + 5 min 'Mix manually' = 13 minutes self.assertEqual(report_values['lines']['operations_time'], 660.0, 'Operation time should be the same for 1 unit or for the batch') - def test_21_bom_report_variant(self): """ Test a sub BoM process with multiple variants. BOM 1: @@ -1069,7 +1068,7 @@ class TestBoM(TestMrpCommon): line.product_qty = 5 with self.assertRaises(exceptions.ValidationError), self.cr.savepoint(): bom_finished = bom_finished.save() - + def test_validate_bom_line_with_different_product_variant(self): """ Can set a BOM line on a BOM with a different product variant as the BOM itself (same product) @@ -1143,6 +1142,195 @@ class TestBoM(TestMrpCommon): self.assertEqual(orderpoint.qty_multiple, 2000.0) self.assertEqual(orderpoint.qty_to_order, 4000.0) + def test_bom_generated_from_mo(self): + """ Creates a Manufacturing Order without BoM, then uses it to generate a new BoM. + Checks the generated BoM has the expected BoM lines, by-products and operations. + """ + # Creates some products. + common_vals = {'type': "product"} + finished_product = self.env['product.product'].create(dict(common_vals, name="Monster in Jar")) + component_1 = self.env['product.product'].create(dict(common_vals, name="Monster")) + component_2 = self.env['product.product'].create(dict(common_vals, name="Jar")) + by_product = self.env['product.product'].create(dict(common_vals, name="Monster's Tears")) + + def create_mo(qty_to_produce=1): + # Creates a MO using some components. + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = finished_product + mo_form.product_qty = qty_to_produce + for component in [component_1, component_2]: + with mo_form.move_raw_ids.new() as raw_move: + raw_move.product_id = component + raw_move.product_uom_qty = qty_to_produce + return mo_form.save() + + def generate_bom_from_mo(mo): + action_generate_new_bom = mo.action_generate_bom() + return Form(self.env['mrp.bom'].with_context(action_generate_new_bom['context'])).save() + + # Generates a BoM from a draft MO. + mo_1 = create_mo() + bom_from_mo_1 = generate_bom_from_mo(mo_1) + # Checks the MO's data. + self.assertEqual(mo_1.bom_id, bom_from_mo_1, "New BoM was assigned to the draft MO") + self.assertEqual(len(mo_1.move_raw_ids), 2) + self.assertEqual(mo_1.move_raw_ids[0].bom_line_id, bom_from_mo_1.bom_line_ids[0]) + self.assertEqual(mo_1.move_raw_ids[1].bom_line_id, bom_from_mo_1.bom_line_ids[1]) + # Checks the BoM's data. + self.assertEqual(bom_from_mo_1.product_id, finished_product) + self.assertRecordValues(bom_from_mo_1.bom_line_ids, [ + {'product_id': component_1.id, 'product_qty': 1}, + {'product_id': component_2.id, 'product_qty': 1}, + ]) + bom_from_mo_1.active = False # Archives the created BoM to avoid to use it for the next MOs + + # Generates a BoM from a done MO. + mo_2 = create_mo() + mo_2.action_confirm() + mo_2.button_mark_done() + move_ids = mo_2.move_raw_ids.ids + bom_from_mo_2 = generate_bom_from_mo(mo_2) + bom_lines = bom_from_mo_2.bom_line_ids + # Checks the MO's data. + self.assertEqual(mo_2.bom_id, bom_from_mo_2, "New BoM should be assigned to the done MO") + # The move lines weren't linked to the BoM lines since the MO is done. + self.assertRecordValues(mo_2.move_raw_ids, [ + {'bom_line_id': False, 'id': move_ids[0], 'product_id': bom_lines[0].product_id.id}, + {'bom_line_id': False, 'id': move_ids[1], 'product_id': bom_lines[1].product_id.id}, + ]) + # Checks the BoM's data. + self.assertEqual(bom_from_mo_2.product_id, finished_product) + self.assertRecordValues(bom_from_mo_2.bom_line_ids, [ + {'product_id': component_1.id, 'product_qty': 1}, + {'product_id': component_2.id, 'product_qty': 1}, + ]) + bom_from_mo_2.active = False # Archives the created BoM to avoid to use it for the next MOs + + # Generates a BoM from a confirmed MO using operations and by-products. + self.env.user.groups_id += self.env.ref('mrp.group_mrp_byproducts') # Enables by-products. + self.env.user.groups_id += self.env.ref('mrp.group_mrp_routings') # Enables workorders. + # Produces 3 qties to check if the operations' duration will be correctly divided by 3. + mo_3 = create_mo(3) + mo_3.action_confirm() + mo_form = Form(mo_3) + with mo_form.workorder_ids.new() as operation: + operation.name = "Kiss the Monster on its Forehead 😘" + operation.workcenter_id = self.workcenter_1 + operation.duration_expected = 60 + with mo_form.workorder_ids.new() as operation: + operation.name = "Put the Monster in the Jar!" + operation.workcenter_id = self.workcenter_2 + operation.duration_expected = 480 + with mo_form.move_byproduct_ids.new() as by_product_line: + by_product_line.product_id = by_product + by_product_line.quantity_done = 6 + mo_3 = mo_form.save() + bom_from_mo_3 = generate_bom_from_mo(mo_3) + # Checks the MO's data. + self.assertEqual(mo_3.bom_id, bom_from_mo_3, "New BoM was assigned to the confirmed MO") + self.assertEqual(mo_3.move_raw_ids[0].bom_line_id, bom_from_mo_3.bom_line_ids[0]) + self.assertEqual(mo_3.move_raw_ids[1].bom_line_id, bom_from_mo_3.bom_line_ids[1]) + self.assertEqual(mo_3.workorder_ids[0].operation_id, bom_from_mo_3.operation_ids[0]) + self.assertEqual(mo_3.workorder_ids[1].operation_id, bom_from_mo_3.operation_ids[1]) + self.assertEqual(mo_3.move_byproduct_ids.byproduct_id, bom_from_mo_3.byproduct_ids) + # Checks the BoM's data. + self.assertEqual(mo_3.bom_id, bom_from_mo_3) + self.assertEqual(bom_from_mo_3.product_id, finished_product) + self.assertEqual(bom_from_mo_3.product_qty, 3) + self.assertRecordValues(bom_from_mo_3.bom_line_ids, [ + {'product_id': component_1.id, 'product_qty': 3}, + {'product_id': component_2.id, 'product_qty': 3}, + ]) + # Durations should be divided by 3 (op1: 60 / 3 = 20; op2: 480 / 3 : 160). + self.assertRecordValues(bom_from_mo_3.operation_ids, [ + {'name': "Kiss the Monster on its Forehead 😘", 'workcenter_id': self.workcenter_1.id, 'time_cycle': 20}, + {'name': "Put the Monster in the Jar!", 'workcenter_id': self.workcenter_2.id, 'time_cycle': 160}, + ]) + self.assertEqual(bom_from_mo_3.byproduct_ids.product_id, by_product) + self.assertEqual(bom_from_mo_3.byproduct_ids.product_qty, 6) + + def test_bom_generated_from_mo_with_different_uom(self): + """ Creates a Manufacturing Order without BoM and using different UoM for its raw moves, + then to generate a new BoM from this MO. + Checks the generated BoM has the expected BoM lines UoM and quantity. + """ + self.env.user.groups_id += self.env.ref('uom.group_uom') + uom_unit = self.env.ref('uom.product_uom_unit') + uom_dozen = self.env.ref('uom.product_uom_dozen') + # Creates some products. + common_vals = {'type': "product"} + finished_product = self.env['product.product'].create(dict(common_vals, name="CO² Molecule")) + component_1 = self.env['product.product'].create(dict(common_vals, name="Carbon Molecule")) + component_2 = self.env['product.product'].create(dict(common_vals, name="Oxygen Molecule")) + # Creates a MO. + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = finished_product + mo_form.product_qty = 1 + mo_form.product_uom_id = uom_dozen + with mo_form.move_raw_ids.new() as raw_move: + raw_move.product_id = component_1 + raw_move.product_uom_qty = 12 + raw_move.product_uom = uom_unit + with mo_form.move_raw_ids.new() as raw_move: + raw_move.product_id = component_2 + raw_move.product_uom_qty = 2 + raw_move.product_uom = uom_dozen + mo = mo_form.save() + mo.action_confirm() + # Generates a BoM from the MO and checks its values. + action_generate_new_bom = mo.action_generate_bom() + bom_form = Form(self.env['mrp.bom'].with_context(action_generate_new_bom['context'])) + bom_from_mo = bom_form.save() + self.assertEqual(bom_from_mo.product_uom_id, uom_dozen) + self.assertEqual(bom_from_mo.product_qty, 1) + self.assertRecordValues(bom_from_mo.bom_line_ids, [ + {'product_id': component_1.id, 'product_qty': 12, 'product_uom_id': uom_unit.id}, + {'product_id': component_2.id, 'product_qty': 2, 'product_uom_id': uom_dozen.id}, + ]) + + def test_bom_generated_from_mo_with_byproducts(self): + """ Creates a Manufacturing Order without BoM then to generate a new BoM from this MO and + modifies by-products values. + """ + self.env.user.groups_id += self.env.ref('mrp.group_mrp_byproducts') # Enables by-products. + # Creates some products. + common_vals = {'type': "product"} + finished_product = self.env['product.product'].create(dict(common_vals, name="Banana Bread")) + component_1 = self.env['product.product'].create(dict(common_vals, name="Banana")) + component_2 = self.env['product.product'].create(dict(common_vals, name="Sugar, Spice and Everything Nice")) + by_product = self.env['product.product'].create(dict(common_vals, name="Banana Peels")) + # Creates a MO. + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = finished_product + mo_form.product_qty = 1 + with mo_form.move_raw_ids.new() as raw_move: + raw_move.product_id = component_1 + raw_move.product_uom_qty = 1 + with mo_form.move_raw_ids.new() as raw_move: + raw_move.product_id = component_2 + raw_move.product_uom_qty = 1 + with mo_form.move_byproduct_ids.new() as by_product_move: + by_product_move.product_id = by_product + by_product_move.product_uom_qty = 1 + by_product_move.cost_share = 50 + mo = mo_form.save() + mo.action_confirm() + self.assertEqual(mo.move_byproduct_ids.cost_share, 50) + # Generates a BoM from the MO and checks its values. + action_generate_new_bom = mo.action_generate_bom() + bom_form = Form(self.env['mrp.bom'].with_context(action_generate_new_bom['context'])) + with bom_form.bom_line_ids.edit(0) as bom_line: + bom_line.product_qty = 3 + with bom_form.byproduct_ids.edit(0) as bom_by_product: + bom_by_product.product_qty = 3 + bom_by_product.cost_share = 10 + bom_from_mo = bom_form.save() + self.assertEqual(mo.bom_id, bom_from_mo) + self.assertEqual(mo.move_raw_ids[0].product_uom_qty, 3) + self.assertEqual(mo.move_raw_ids[1].product_uom_qty, 1) + self.assertEqual(mo.move_byproduct_ids.cost_share, 10) + self.assertEqual(mo.move_byproduct_ids.product_uom_qty, 3) + def test_bom_kit_with_sub_kit(self): p1, p2, p3, p4 = self.make_prods(4) self.make_bom(p1, p2, p3) diff --git a/addons/mrp/views/mrp_bom_views.xml b/addons/mrp/views/mrp_bom_views.xml index a38f67a9c0d..b49ff6d95d1 100644 --- a/addons/mrp/views/mrp_bom_views.xml +++ b/addons/mrp/views/mrp_bom_views.xml @@ -38,7 +38,7 @@
-
+