diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/mrp/tests | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/mrp/tests')
| -rw-r--r-- | addons/mrp/tests/__init__.py | 15 | ||||
| -rw-r--r-- | addons/mrp/tests/common.py | 156 | ||||
| -rw-r--r-- | addons/mrp/tests/test_backorder.py | 360 | ||||
| -rw-r--r-- | addons/mrp/tests/test_bom.py | 832 | ||||
| -rw-r--r-- | addons/mrp/tests/test_byproduct.py | 118 | ||||
| -rw-r--r-- | addons/mrp/tests/test_cancel_mo.py | 103 | ||||
| -rw-r--r-- | addons/mrp/tests/test_multicompany.py | 187 | ||||
| -rw-r--r-- | addons/mrp/tests/test_oee.py | 71 | ||||
| -rw-r--r-- | addons/mrp/tests/test_order.py | 2055 | ||||
| -rw-r--r-- | addons/mrp/tests/test_procurement.py | 598 | ||||
| -rw-r--r-- | addons/mrp/tests/test_stock.py | 350 | ||||
| -rw-r--r-- | addons/mrp/tests/test_stock_report.py | 153 | ||||
| -rw-r--r-- | addons/mrp/tests/test_traceability.py | 308 | ||||
| -rw-r--r-- | addons/mrp/tests/test_unbuild.py | 599 | ||||
| -rw-r--r-- | addons/mrp/tests/test_warehouse_multistep_manufacturing.py | 507 |
15 files changed, 6412 insertions, 0 deletions
diff --git a/addons/mrp/tests/__init__.py b/addons/mrp/tests/__init__.py new file mode 100644 index 00000000..73cf6398 --- /dev/null +++ b/addons/mrp/tests/__init__.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- + +from . import test_bom +from . import test_byproduct +from . import test_cancel_mo +from . import test_order +from . import test_stock +from . import test_stock_report +from . import test_warehouse_multistep_manufacturing +from . import test_procurement +from . import test_unbuild +from . import test_oee +from . import test_traceability +from . import test_multicompany +from . import test_backorder diff --git a/addons/mrp/tests/common.py b/addons/mrp/tests/common.py new file mode 100644 index 00000000..502b4a94 --- /dev/null +++ b/addons/mrp/tests/common.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- + +from odoo.tests import Form +from odoo.addons.mail.tests.common import mail_new_test_user +from odoo.addons.stock.tests import common2 + + +class TestMrpCommon(common2.TestStockCommon): + + @classmethod + def generate_mo(self, tracking_final='none', tracking_base_1='none', tracking_base_2='none', qty_final=5, qty_base_1=4, qty_base_2=1, picking_type_id=False, consumption=False): + """ This function generate a manufacturing order with one final + product and two consumed product. Arguments allows to choose + the tracking/qty for each different products. It returns the + MO, used bom and the tree products. + """ + product_to_build = self.env['product.product'].create({ + 'name': 'Young Tom', + 'type': 'product', + 'tracking': tracking_final, + }) + product_to_use_1 = self.env['product.product'].create({ + 'name': 'Botox', + 'type': 'product', + 'tracking': tracking_base_1, + }) + product_to_use_2 = self.env['product.product'].create({ + 'name': 'Old Tom', + 'type': 'product', + 'tracking': tracking_base_2, + }) + bom_1 = 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, + 'type': 'normal', + 'consumption': consumption if consumption else 'flexible', + 'bom_line_ids': [ + (0, 0, {'product_id': product_to_use_2.id, 'product_qty': qty_base_2}), + (0, 0, {'product_id': product_to_use_1.id, 'product_qty': qty_base_1}) + ]}) + mo_form = Form(self.env['mrp.production']) + if picking_type_id: + mo_form.picking_type_id = picking_type_id + mo_form.product_id = product_to_build + mo_form.bom_id = bom_1 + mo_form.product_qty = qty_final + mo = mo_form.save() + mo.action_confirm() + return mo, bom_1, product_to_build, product_to_use_1, product_to_use_2 + + @classmethod + def setUpClass(cls): + super(TestMrpCommon, cls).setUpClass() + + # Update demo products + (cls.product_2 | cls.product_3 | cls.product_4 | cls.product_5 | cls.product_6 | cls.product_7_3 | cls.product_8).write({ + 'type': 'product', + }) + + # User Data: mrp user and mrp manager + cls.user_mrp_user = mail_new_test_user( + cls.env, + name='Hilda Ferachwal', + login='hilda', + email='h.h@example.com', + notification_type='inbox', + groups='mrp.group_mrp_user, stock.group_stock_user, mrp.group_mrp_byproducts', + ) + cls.user_mrp_manager = mail_new_test_user( + cls.env, + name='Gary Youngwomen', + login='gary', + email='g.g@example.com', + notification_type='inbox', + groups='mrp.group_mrp_manager, stock.group_stock_user, mrp.group_mrp_byproducts', + ) + + cls.workcenter_1 = cls.env['mrp.workcenter'].create({ + 'name': 'Nuclear Workcenter', + 'capacity': 2, + 'time_start': 10, + 'time_stop': 5, + 'time_efficiency': 80, + }) + + cls.bom_1 = cls.env['mrp.bom'].create({ + 'product_id': cls.product_4.id, + 'product_tmpl_id': cls.product_4.product_tmpl_id.id, + 'product_uom_id': cls.uom_unit.id, + 'product_qty': 4.0, + 'consumption': 'flexible', + 'operation_ids': [ + ], + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': cls.product_2.id, 'product_qty': 2}), + (0, 0, {'product_id': cls.product_1.id, 'product_qty': 4}) + ]}) + cls.bom_2 = cls.env['mrp.bom'].create({ + 'product_id': cls.product_5.id, + 'product_tmpl_id': cls.product_5.product_tmpl_id.id, + 'product_uom_id': cls.product_5.uom_id.id, + 'consumption': 'flexible', + 'product_qty': 1.0, + 'operation_ids': [ + (0, 0, {'name': 'Gift Wrap Maching', 'workcenter_id': cls.workcenter_1.id, 'time_cycle': 15, 'sequence': 1}), + ], + 'type': 'phantom', + 'sequence': 2, + 'bom_line_ids': [ + (0, 0, {'product_id': cls.product_4.id, 'product_qty': 2}), + (0, 0, {'product_id': cls.product_3.id, 'product_qty': 3}) + ]}) + cls.bom_3 = cls.env['mrp.bom'].create({ + 'product_id': cls.product_6.id, + 'product_tmpl_id': cls.product_6.product_tmpl_id.id, + 'product_uom_id': cls.uom_dozen.id, + 'consumption': 'flexible', + 'product_qty': 2.0, + 'operation_ids': [ + (0, 0, {'name': 'Cutting Machine', 'workcenter_id': cls.workcenter_1.id, 'time_cycle': 12, 'sequence': 1}), + (0, 0, {'name': 'Weld Machine', 'workcenter_id': cls.workcenter_1.id, 'time_cycle': 18, 'sequence': 2}), + ], + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': cls.product_5.id, 'product_qty': 2}), + (0, 0, {'product_id': cls.product_4.id, 'product_qty': 8}), + (0, 0, {'product_id': cls.product_2.id, 'product_qty': 12}) + ]}) + + cls.stock_location_14 = cls.env['stock.location'].create({ + 'name': 'Shelf 2', + 'location_id': cls.env.ref('stock.warehouse0').lot_stock_id.id, + }) + cls.stock_location_components = cls.env['stock.location'].create({ + 'name': 'Shelf 1', + 'location_id': cls.env.ref('stock.warehouse0').lot_stock_id.id, + }) + cls.laptop = cls.env['product.product'].create({ + 'name': 'Acoustic Bloc Screens', + 'uom_id': cls.env.ref("uom.product_uom_unit").id, + 'uom_po_id': cls.env.ref("uom.product_uom_unit").id, + 'type': 'product', + 'tracking': 'none', + 'categ_id': cls.env.ref('product.product_category_all').id, + }) + cls.graphics_card = cls.env['product.product'].create({ + 'name': 'Individual Workplace', + 'uom_id': cls.env.ref("uom.product_uom_unit").id, + 'uom_po_id': cls.env.ref("uom.product_uom_unit").id, + 'type': 'product', + 'tracking': 'none', + 'categ_id': cls.env.ref('product.product_category_all').id, + }) diff --git a/addons/mrp/tests/test_backorder.py b/addons/mrp/tests/test_backorder.py new file mode 100644 index 00000000..eadd7847 --- /dev/null +++ b/addons/mrp/tests/test_backorder.py @@ -0,0 +1,360 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.mrp.tests.common import TestMrpCommon +from odoo.tests import Form +from odoo.tests.common import SavepointCase + + +class TestMrpProductionBackorder(TestMrpCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.stock_location = cls.env.ref('stock.stock_location_stock') + + def setUp(self): + super().setUp() + warehouse_form = Form(self.env['stock.warehouse']) + warehouse_form.name = 'Test Warehouse' + warehouse_form.code = 'TWH' + self.warehouse = warehouse_form.save() + + def test_no_tracking_1(self): + """Create a MO for 4 product. Produce 4. The backorder button should + not appear and hitting mark as done should not open the backorder wizard. + The name of the MO should be MO/001. + """ + mo = self.generate_mo(qty_final=4)[0] + + mo_form = Form(mo) + mo_form.qty_producing = 4 + mo = mo_form.save() + + # No backorder is proposed + self.assertTrue(mo.button_mark_done()) + self.assertEqual(mo._get_quantity_to_backorder(), 0) + self.assertTrue("-001" not in mo.name) + + def test_no_tracking_2(self): + """Create a MO for 4 product. Produce 1. The backorder button should + appear and hitting mark as done should open the backorder wizard. In the backorder + wizard, choose to do the backorder. A new MO for 3 self.untracked_bom should be + created. + The sequence of the first MO should be MO/001-01, the sequence of the second MO + should be MO/001-02. + Check that all MO are reachable through the procurement group. + """ + production, _, _, product_to_use_1, _ = self.generate_mo(qty_final=4, qty_base_1=3) + self.assertEqual(production.state, 'confirmed') + self.assertEqual(production.reserve_visible, True) + + # Make some stock and reserve + for product in production.move_raw_ids.product_id: + self.env['stock.quant'].with_context(inventory_mode=True).create({ + 'product_id': product.id, + 'inventory_quantity': 100, + 'location_id': production.location_src_id.id, + }) + production.action_assign() + self.assertEqual(production.state, 'confirmed') + self.assertEqual(production.reserve_visible, False) + + mo_form = Form(production) + mo_form.qty_producing = 1 + production = mo_form.save() + + action = production.button_mark_done() + backorder = Form(self.env['mrp.production.backorder'].with_context(**action['context'])) + backorder.save().action_backorder() + + # Two related MO to the procurement group + self.assertEqual(len(production.procurement_group_id.mrp_production_ids), 2) + + # Check MO backorder + mo_backorder = production.procurement_group_id.mrp_production_ids[-1] + self.assertEqual(mo_backorder.product_id.id, production.product_id.id) + self.assertEqual(mo_backorder.product_qty, 3) + self.assertEqual(sum(mo_backorder.move_raw_ids.filtered(lambda m: m.product_id.id == product_to_use_1.id).mapped("product_uom_qty")), 9) + self.assertEqual(mo_backorder.reserve_visible, False) # the reservation of the first MO should've been moved here + + def test_no_tracking_pbm_1(self): + """Create a MO for 4 product. Produce 1. The backorder button should + appear and hitting mark as done should open the backorder wizard. In the backorder + wizard, choose to do the backorder. A new MO for 3 self.untracked_bom should be + created. + The sequence of the first MO should be MO/001-01, the sequence of the second MO + should be MO/001-02. + Check that all MO are reachable through the procurement group. + """ + with Form(self.warehouse) as warehouse: + warehouse.manufacture_steps = 'pbm' + + production, _, product_to_build, product_to_use_1, product_to_use_2 = self.generate_mo(qty_base_1=4, qty_final=4, picking_type_id=self.warehouse.manu_type_id) + + move_raw_ids = production.move_raw_ids + self.assertEqual(len(move_raw_ids), 2) + self.assertEqual(set(move_raw_ids.mapped("product_id")), {product_to_use_1, product_to_use_2}) + + pbm_move = move_raw_ids.move_orig_ids + self.assertEqual(len(pbm_move), 2) + self.assertEqual(set(pbm_move.mapped("product_id")), {product_to_use_1, product_to_use_2}) + self.assertFalse(pbm_move.move_orig_ids) + + mo_form = Form(production) + mo_form.qty_producing = 1 + production = mo_form.save() + self.assertEqual(sum(pbm_move.filtered(lambda m: m.product_id.id == product_to_use_1.id).mapped("product_qty")), 16) + self.assertEqual(sum(pbm_move.filtered(lambda m: m.product_id.id == product_to_use_2.id).mapped("product_qty")), 4) + + action = production.button_mark_done() + backorder = Form(self.env['mrp.production.backorder'].with_context(**action['context'])) + backorder.save().action_backorder() + + mo_backorder = production.procurement_group_id.mrp_production_ids[-1] + self.assertEqual(mo_backorder.delivery_count, 1) + + pbm_move |= mo_backorder.move_raw_ids.move_orig_ids + # Check that quantity is correct + self.assertEqual(sum(pbm_move.filtered(lambda m: m.product_id.id == product_to_use_1.id).mapped("product_qty")), 16) + self.assertEqual(sum(pbm_move.filtered(lambda m: m.product_id.id == product_to_use_2.id).mapped("product_qty")), 4) + + self.assertFalse(pbm_move.move_orig_ids) + + def test_no_tracking_pbm_sam_1(self): + """Create a MO for 4 product. Produce 1. The backorder button should + appear and hitting mark as done should open the backorder wizard. In the backorder + wizard, choose to do the backorder. A new MO for 3 self.untracked_bom should be + created. + The sequence of the first MO should be MO/001-01, the sequence of the second MO + should be MO/001-02. + Check that all MO are reachable through the procurement group. + """ + with Form(self.warehouse) as warehouse: + warehouse.manufacture_steps = 'pbm_sam' + production, _, product_to_build, product_to_use_1, product_to_use_2 = self.generate_mo(qty_base_1=4, qty_final=4, picking_type_id=self.warehouse.manu_type_id) + + move_raw_ids = production.move_raw_ids + self.assertEqual(len(move_raw_ids), 2) + self.assertEqual(set(move_raw_ids.mapped("product_id")), {product_to_use_1, product_to_use_2}) + + pbm_move = move_raw_ids.move_orig_ids + self.assertEqual(len(pbm_move), 2) + self.assertEqual(set(pbm_move.mapped("product_id")), {product_to_use_1, product_to_use_2}) + self.assertFalse(pbm_move.move_orig_ids) + self.assertEqual(sum(pbm_move.filtered(lambda m: m.product_id.id == product_to_use_1.id).mapped("product_qty")), 16) + self.assertEqual(sum(pbm_move.filtered(lambda m: m.product_id.id == product_to_use_2.id).mapped("product_qty")), 4) + + sam_move = production.move_finished_ids.move_dest_ids + self.assertEqual(len(sam_move), 1) + self.assertEqual(sam_move.product_id.id, product_to_build.id) + self.assertEqual(sum(sam_move.mapped("product_qty")), 4) + + mo_form = Form(production) + mo_form.qty_producing = 1 + production = mo_form.save() + + action = production.button_mark_done() + backorder = Form(self.env['mrp.production.backorder'].with_context(**action['context'])) + backorder.save().action_backorder() + + mo_backorder = production.procurement_group_id.mrp_production_ids[-1] + self.assertEqual(mo_backorder.delivery_count, 2) + + pbm_move |= mo_backorder.move_raw_ids.move_orig_ids + self.assertEqual(sum(pbm_move.filtered(lambda m: m.product_id.id == product_to_use_1.id).mapped("product_qty")), 16) + self.assertEqual(sum(pbm_move.filtered(lambda m: m.product_id.id == product_to_use_2.id).mapped("product_qty")), 4) + + sam_move |= mo_backorder.move_finished_ids.move_orig_ids + self.assertEqual(sum(sam_move.mapped("product_qty")), 4) + + def test_tracking_backorder_series_lot_1(self): + """ Create a MO of 4 tracked products. all component is tracked by lots + Produce one by one with one bakorder for each until end. + """ + nb_product_todo = 4 + production, _, p_final, p1, p2 = self.generate_mo(qty_final=nb_product_todo, tracking_final='lot', tracking_base_1='lot', tracking_base_2='lot') + lot_final = self.env['stock.production.lot'].create({ + 'name': 'lot_final', + 'product_id': p_final.id, + 'company_id': self.env.company.id, + }) + lot_1 = self.env['stock.production.lot'].create({ + 'name': 'lot_consumed_1', + 'product_id': p1.id, + 'company_id': self.env.company.id, + }) + lot_2 = self.env['stock.production.lot'].create({ + 'name': 'lot_consumed_2', + 'product_id': p2.id, + 'company_id': self.env.company.id, + }) + + self.env['stock.quant']._update_available_quantity(p1, self.stock_location, nb_product_todo*4, lot_id=lot_1) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, nb_product_todo, lot_id=lot_2) + + production.action_assign() + active_production = production + for i in range(nb_product_todo): + + details_operation_form = Form(active_production.move_raw_ids.filtered(lambda m: m.product_id == p1), view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.edit(0) as ml: + ml.qty_done = 4 + ml.lot_id = lot_1 + details_operation_form.save() + details_operation_form = Form(active_production.move_raw_ids.filtered(lambda m: m.product_id == p2), view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.edit(0) as ml: + ml.qty_done = 1 + ml.lot_id = lot_2 + details_operation_form.save() + + production_form = Form(active_production) + production_form.qty_producing = 1 + production_form.lot_producing_id = lot_final + active_production = production_form.save() + + active_production.button_mark_done() + if i + 1 != nb_product_todo: # If last MO, don't make a backorder + action = active_production.button_mark_done() + backorder = Form(self.env['mrp.production.backorder'].with_context(**action['context'])) + backorder.save().action_backorder() + active_production = active_production.procurement_group_id.mrp_production_ids[-1] + + self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location, lot_id=lot_final), nb_product_todo, f'You should have the {nb_product_todo} final product in stock') + self.assertEqual(len(production.procurement_group_id.mrp_production_ids), nb_product_todo) + + def test_tracking_backorder_series_serial_1(self): + """ Create a MO of 4 tracked products (serial) with pbm_sam. + all component is tracked by serial + Produce one by one with one bakorder for each until end. + """ + nb_product_todo = 4 + production, _, p_final, p1, p2 = self.generate_mo(qty_final=nb_product_todo, tracking_final='serial', tracking_base_1='serial', tracking_base_2='serial', qty_base_1=1) + serials_final, serials_p1, serials_p2 = [], [], [] + for i in range(nb_product_todo): + serials_final.append(self.env['stock.production.lot'].create({ + 'name': f'lot_final_{i}', + 'product_id': p_final.id, + 'company_id': self.env.company.id, + })) + serials_p1.append(self.env['stock.production.lot'].create({ + 'name': f'lot_consumed_1_{i}', + 'product_id': p1.id, + 'company_id': self.env.company.id, + })) + serials_p2.append(self.env['stock.production.lot'].create({ + 'name': f'lot_consumed_2_{i}', + 'product_id': p2.id, + 'company_id': self.env.company.id, + })) + self.env['stock.quant']._update_available_quantity(p1, self.stock_location, 1, lot_id=serials_p1[-1]) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 1, lot_id=serials_p2[-1]) + + production.action_assign() + active_production = production + for i in range(nb_product_todo): + + details_operation_form = Form(active_production.move_raw_ids.filtered(lambda m: m.product_id == p1), view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.edit(0) as ml: + ml.qty_done = 1 + ml.lot_id = serials_p1[i] + details_operation_form.save() + details_operation_form = Form(active_production.move_raw_ids.filtered(lambda m: m.product_id == p2), view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.edit(0) as ml: + ml.qty_done = 1 + ml.lot_id = serials_p2[i] + details_operation_form.save() + + production_form = Form(active_production) + production_form.qty_producing = 1 + production_form.lot_producing_id = serials_final[i] + active_production = production_form.save() + + active_production.button_mark_done() + if i + 1 != nb_product_todo: # If last MO, don't make a backorder + action = active_production.button_mark_done() + backorder = Form(self.env['mrp.production.backorder'].with_context(**action['context'])) + backorder.save().action_backorder() + active_production = active_production.procurement_group_id.mrp_production_ids[-1] + + self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location), nb_product_todo, f'You should have the {nb_product_todo} final product in stock') + self.assertEqual(len(production.procurement_group_id.mrp_production_ids), nb_product_todo) + + def test_backorder_name(self): + def produce_one(mo): + mo_form = Form(mo) + mo_form.qty_producing = 1 + mo = mo_form.save() + action = mo.button_mark_done() + backorder = Form(self.env['mrp.production.backorder'].with_context(**action['context'])) + 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 = 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) + + backorder = produce_one(production) + self.assertEqual(production.name, initial_mo_name + "-001") + 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)) + self.assertEqual(new_backorder.name, initial_mo_name + "-" + str(seq + 1)) + backorder = new_backorder + + +class TestMrpWorkorderBackorder(SavepointCase): + @classmethod + def setUpClass(cls): + super(TestMrpWorkorderBackorder, cls).setUpClass() + cls.uom_unit = cls.env['uom.uom'].search([ + ('category_id', '=', cls.env.ref('uom.product_uom_categ_unit').id), + ('uom_type', '=', 'reference') + ], limit=1) + cls.finished1 = cls.env['product.product'].create({ + 'name': 'finished1', + 'type': 'product', + }) + cls.compfinished1 = cls.env['product.product'].create({ + 'name': 'compfinished1', + 'type': 'product', + }) + cls.compfinished2 = cls.env['product.product'].create({ + 'name': 'compfinished2', + 'type': 'product', + }) + cls.workcenter1 = cls.env['mrp.workcenter'].create({ + 'name': 'workcenter1', + }) + cls.workcenter2 = cls.env['mrp.workcenter'].create({ + 'name': 'workcenter2', + }) + + cls.bom_finished1 = cls.env['mrp.bom'].create({ + 'product_id': cls.finished1.id, + 'product_tmpl_id': cls.finished1.product_tmpl_id.id, + 'product_uom_id': cls.uom_unit.id, + 'product_qty': 1, + 'consumption': 'flexible', + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': cls.compfinished1.id, 'product_qty': 1}), + (0, 0, {'product_id': cls.compfinished2.id, 'product_qty': 1}), + ], + 'operation_ids': [ + (0, 0, {'sequence': 1, 'name': 'finished operation 1', 'workcenter_id': cls.workcenter1.id}), + (0, 0, {'sequence': 2, 'name': 'finished operation 2', 'workcenter_id': cls.workcenter2.id}), + ], + }) + cls.bom_finished1.bom_line_ids[0].operation_id = cls.bom_finished1.operation_ids[0].id + cls.bom_finished1.bom_line_ids[1].operation_id = cls.bom_finished1.operation_ids[1].id diff --git a/addons/mrp/tests/test_bom.py b/addons/mrp/tests/test_bom.py new file mode 100644 index 00000000..bfa47231 --- /dev/null +++ b/addons/mrp/tests/test_bom.py @@ -0,0 +1,832 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import exceptions +from odoo.tests import Form +from odoo.addons.mrp.tests.common import TestMrpCommon +from odoo.tools import float_compare, float_round + + +class TestBoM(TestMrpCommon): + + def test_01_explode(self): + boms, lines = self.bom_1.explode(self.product_4, 3) + self.assertEqual(set([bom[0].id for bom in boms]), set(self.bom_1.ids)) + self.assertEqual(set([line[0].id for line in lines]), set(self.bom_1.bom_line_ids.ids)) + + boms, lines = self.bom_3.explode(self.product_6, 3) + self.assertEqual(set([bom[0].id for bom in boms]), set((self.bom_2 | self.bom_3).ids)) + self.assertEqual( + set([line[0].id for line in lines]), + set((self.bom_2 | self.bom_3).mapped('bom_line_ids').filtered(lambda line: not line.child_bom_id or line.child_bom_id.type != 'phantom').ids)) + + def test_10_variants(self): + test_bom = self.env['mrp.bom'].create({ + 'product_id': self.product_7_3.id, + 'product_tmpl_id': self.product_7_template.id, + 'product_uom_id': self.uom_unit.id, + 'product_qty': 4.0, + 'type': 'normal', + }) + test_bom.write({ + 'operation_ids': [ + (0, 0, {'name': 'Cutting Machine', 'workcenter_id': self.workcenter_1.id, 'time_cycle': 12, 'sequence': 1}), + (0, 0, {'name': 'Weld Machine', 'workcenter_id': self.workcenter_1.id, 'time_cycle': 18, 'sequence': 2}), + ], + }) + test_bom_l1 = self.env['mrp.bom.line'].create({ + 'bom_id': test_bom.id, + 'product_id': self.product_2.id, + 'product_qty': 2, + }) + test_bom_l2 = self.env['mrp.bom.line'].create({ + 'bom_id': test_bom.id, + 'product_id': self.product_3.id, + 'product_qty': 2, + 'bom_product_template_attribute_value_ids': [(4, self.product_7_attr1_v1.id)], + }) + test_bom_l3 = self.env['mrp.bom.line'].create({ + 'bom_id': test_bom.id, + 'product_id': self.product_4.id, + 'product_qty': 2, + 'bom_product_template_attribute_value_ids': [(4, self.product_7_attr1_v2.id)], + }) + boms, lines = test_bom.explode(self.product_7_3, 4) + self.assertIn(test_bom, [b[0]for b in boms]) + self.assertIn(test_bom_l1, [l[0] for l in lines]) + self.assertNotIn(test_bom_l2, [l[0] for l in lines]) + self.assertNotIn(test_bom_l3, [l[0] for l in lines]) + + boms, lines = test_bom.explode(self.product_7_1, 4) + self.assertIn(test_bom, [b[0]for b in boms]) + self.assertIn(test_bom_l1, [l[0] for l in lines]) + self.assertIn(test_bom_l2, [l[0] for l in lines]) + self.assertNotIn(test_bom_l3, [l[0] for l in lines]) + + boms, lines = test_bom.explode(self.product_7_2, 4) + self.assertIn(test_bom, [b[0]for b in boms]) + self.assertIn(test_bom_l1, [l[0] for l in lines]) + self.assertNotIn(test_bom_l2, [l[0] for l in lines]) + self.assertIn(test_bom_l3, [l[0] for l in lines]) + + def test_11_multi_level_variants(self): + tmp_picking_type = self.env['stock.picking.type'].create({ + 'name': 'Manufacturing', + 'code': 'mrp_operation', + 'sequence_code': 'TMP', + 'sequence_id': self.env['ir.sequence'].create({ + 'code': 'mrp.production', + 'name': 'tmp_production_sequence', + }).id, + }) + test_bom_1 = self.env['mrp.bom'].create({ + 'product_tmpl_id': self.product_5.product_tmpl_id.id, + 'product_uom_id': self.product_5.uom_id.id, + 'product_qty': 1.0, + 'type': 'phantom' + }) + test_bom_1.write({ + 'operation_ids': [ + (0, 0, {'name': 'Gift Wrap Maching', 'workcenter_id': self.workcenter_1.id, 'time_cycle': 15, 'sequence': 1}), + ], + }) + test_bom_1_l1 = self.env['mrp.bom.line'].create({ + 'bom_id': test_bom_1.id, + 'product_id': self.product_3.id, + 'product_qty': 3, + }) + + test_bom_2 = self.env['mrp.bom'].create({ + 'product_id': self.product_7_3.id, + 'product_tmpl_id': self.product_7_template.id, + 'product_uom_id': self.uom_unit.id, + 'product_qty': 4.0, + 'type': 'normal', + }) + test_bom_2.write({ + 'operation_ids': [ + (0, 0, {'name': 'Cutting Machine', 'workcenter_id': self.workcenter_1.id, 'time_cycle': 12, 'sequence': 1}), + (0, 0, {'name': 'Weld Machine', 'workcenter_id': self.workcenter_1.id, 'time_cycle': 18, 'sequence': 2}), + ] + }) + test_bom_2_l1 = self.env['mrp.bom.line'].create({ + 'bom_id': test_bom_2.id, + 'product_id': self.product_2.id, + 'product_qty': 2, + }) + test_bom_2_l2 = self.env['mrp.bom.line'].create({ + 'bom_id': test_bom_2.id, + 'product_id': self.product_5.id, + 'product_qty': 2, + 'bom_product_template_attribute_value_ids': [(4, self.product_7_attr1_v1.id)], + }) + test_bom_2_l3 = self.env['mrp.bom.line'].create({ + 'bom_id': test_bom_2.id, + 'product_id': self.product_5.id, + 'product_qty': 2, + 'bom_product_template_attribute_value_ids': [(4, self.product_7_attr1_v2.id)], + }) + test_bom_2_l4 = self.env['mrp.bom.line'].create({ + 'bom_id': test_bom_2.id, + 'product_id': self.product_4.id, + 'product_qty': 2, + }) + + # check product > product_tmpl + boms, lines = test_bom_2.explode(self.product_7_1, 4) + self.assertEqual(set((test_bom_2 | self.bom_2).ids), set([b[0].id for b in boms])) + self.assertEqual(set((test_bom_2_l1 | test_bom_2_l4 | self.bom_2.bom_line_ids).ids), set([l[0].id for l in lines])) + + # check sequence priority + test_bom_1.write({'sequence': 1}) + boms, lines = test_bom_2.explode(self.product_7_1, 4) + self.assertEqual(set((test_bom_2 | test_bom_1).ids), set([b[0].id for b in boms])) + self.assertEqual(set((test_bom_2_l1 | test_bom_2_l4 | test_bom_1.bom_line_ids).ids), set([l[0].id for l in lines])) + + # check with another picking_type + test_bom_1.write({'picking_type_id': self.warehouse_1.manu_type_id.id}) + self.bom_2.write({'picking_type_id': tmp_picking_type.id}) + test_bom_2.write({'picking_type_id': tmp_picking_type.id}) + boms, lines = test_bom_2.explode(self.product_7_1, 4) + self.assertEqual(set((test_bom_2 | self.bom_2).ids), set([b[0].id for b in boms])) + self.assertEqual(set((test_bom_2_l1 | test_bom_2_l4 | self.bom_2.bom_line_ids).ids), set([l[0].id for l in lines])) + + #check recursion + test_bom_3 = self.env['mrp.bom'].create({ + 'product_id': self.product_9.id, + 'product_tmpl_id': self.product_9.product_tmpl_id.id, + 'product_uom_id': self.product_9.uom_id.id, + 'product_qty': 1.0, + 'consumption': 'flexible', + 'type': 'normal' + }) + test_bom_4 = self.env['mrp.bom'].create({ + 'product_id': self.product_10.id, + 'product_tmpl_id': self.product_10.product_tmpl_id.id, + 'product_uom_id': self.product_10.uom_id.id, + 'product_qty': 1.0, + 'consumption': 'flexible', + 'type': 'phantom' + }) + test_bom_3_l1 = self.env['mrp.bom.line'].create({ + 'bom_id': test_bom_3.id, + 'product_id': self.product_10.id, + 'product_qty': 1.0, + }) + test_bom_4_l1 = self.env['mrp.bom.line'].create({ + 'bom_id': test_bom_4.id, + 'product_id': self.product_9.id, + 'product_qty': 1.0, + }) + with self.assertRaises(exceptions.UserError): + test_bom_3.explode(self.product_9, 1) + + def test_12_multi_level_variants2(self): + """Test skip bom line with same attribute values in bom lines.""" + + Product = self.env['product.product'] + ProductAttribute = self.env['product.attribute'] + ProductAttributeValue = self.env['product.attribute.value'] + + # Product Attribute + att_color = ProductAttribute.create({'name': 'Color', 'sequence': 1}) + att_size = ProductAttribute.create({'name': 'size', 'sequence': 2}) + + # Product Attribute color Value + att_color_red = ProductAttributeValue.create({'name': 'red', 'attribute_id': att_color.id, 'sequence': 1}) + att_color_blue = ProductAttributeValue.create({'name': 'blue', 'attribute_id': att_color.id, 'sequence': 2}) + # Product Attribute size Value + att_size_big = ProductAttributeValue.create({'name': 'big', 'attribute_id': att_size.id, 'sequence': 1}) + att_size_medium = ProductAttributeValue.create({'name': 'medium', 'attribute_id': att_size.id, 'sequence': 2}) + + # Create Template Product + product_template = self.env['product.template'].create({ + 'name': 'Sofa', + 'attribute_line_ids': [ + (0, 0, { + 'attribute_id': att_color.id, + 'value_ids': [(6, 0, [att_color_red.id, att_color_blue.id])] + }), + (0, 0, { + 'attribute_id': att_size.id, + 'value_ids': [(6, 0, [att_size_big.id, att_size_medium.id])] + }) + ] + }) + + sofa_red = product_template.attribute_line_ids[0].product_template_value_ids[0] + sofa_blue = product_template.attribute_line_ids[0].product_template_value_ids[1] + + sofa_big = product_template.attribute_line_ids[1].product_template_value_ids[0] + sofa_medium = product_template.attribute_line_ids[1].product_template_value_ids[1] + + # Create components Of BOM + product_A = Product.create({ + 'name': 'Wood'}) + product_B = Product.create({ + 'name': 'Clothes'}) + + # Create BOM + self.env['mrp.bom'].create({ + 'product_tmpl_id': product_template.id, + 'product_qty': 1.0, + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, { + 'product_id': product_A.id, + 'product_qty': 1, + 'bom_product_template_attribute_value_ids': [(4, sofa_red.id), (4, sofa_blue.id), (4, sofa_big.id)], + }), + (0, 0, { + 'product_id': product_B.id, + 'product_qty': 1, + 'bom_product_template_attribute_value_ids': [(4, sofa_red.id), (4, sofa_blue.id)] + }) + ] + }) + + dict_consumed_products = { + sofa_red + sofa_big: product_A + product_B, + sofa_red + sofa_medium: product_B, + sofa_blue + sofa_big: product_A + product_B, + sofa_blue + sofa_medium: product_B, + } + + # Create production order for all variants. + for combination, consumed_products in dict_consumed_products.items(): + product = product_template.product_variant_ids.filtered(lambda p: p.product_template_attribute_value_ids == combination) + mrp_order_form = Form(self.env['mrp.production']) + mrp_order_form.product_id = product + mrp_order = mrp_order_form.save() + + # Check consumed materials in production order. + self.assertEqual(mrp_order.move_raw_ids.product_id, consumed_products) + + def test_13_bom_kit_qty(self): + self.env['mrp.bom'].create({ + 'product_id': self.product_7_3.id, + 'product_tmpl_id': self.product_7_template.id, + 'product_uom_id': self.uom_unit.id, + 'product_qty': 4.0, + 'type': 'phantom', + 'bom_line_ids': [ + (0, 0, { + 'product_id': self.product_2.id, + 'product_qty': 2, + }), + (0, 0, { + 'product_id': self.product_3.id, + 'product_qty': 2, + }) + ] + }) + location = self.env.ref('stock.stock_location_stock') + self.env['stock.quant']._update_available_quantity(self.product_2, location, 4.0) + self.env['stock.quant']._update_available_quantity(self.product_3, location, 8.0) + # Force the kit product available qty to be computed at the same time than its component quantities + # Because `qty_available` of a bom kit "recurse" on `qty_available` of its component, + # and this is a tricky thing for the ORM: + # `qty_available` gets called for `product_7_3`, `product_2` and `product_3` + # which then recurse on calling `qty_available` for `product_2` and `product_3` to compute the quantity of + # the kit `product_7_3`. `product_2` and `product_3` gets protected at the first call of the compute method, + # ending the recurse call to not call the compute method and just left the Falsy value `0.0` + # for the components available qty. + kit_product_qty, _, _ = (self.product_7_3 + self.product_2 + self.product_3).mapped("qty_available") + self.assertEqual(kit_product_qty, 2) + + def test_20_bom_report(self): + """ Simulate a crumble receipt with mrp and open the bom structure + report and check that data insde are correct. + """ + uom_kg = self.env.ref('uom.product_uom_kgm') + uom_litre = self.env.ref('uom.product_uom_litre') + crumble = self.env['product.product'].create({ + 'name': 'Crumble', + 'type': 'product', + 'uom_id': uom_kg.id, + 'uom_po_id': uom_kg.id, + }) + butter = self.env['product.product'].create({ + 'name': 'Butter', + 'type': 'product', + 'uom_id': uom_kg.id, + 'uom_po_id': uom_kg.id, + 'standard_price': 7.01 + }) + biscuit = self.env['product.product'].create({ + 'name': 'Biscuit', + 'type': 'product', + 'uom_id': uom_kg.id, + 'uom_po_id': uom_kg.id, + 'standard_price': 1.5 + }) + bom_form_crumble = Form(self.env['mrp.bom']) + bom_form_crumble.product_tmpl_id = crumble.product_tmpl_id + bom_form_crumble.product_qty = 11 + bom_form_crumble.product_uom_id = uom_kg + bom_crumble = bom_form_crumble.save() + + workcenter = self.env['mrp.workcenter'].create({ + 'costs_hour': 10, + 'name': 'Deserts Table' + }) + + with Form(bom_crumble) as bom: + with bom.bom_line_ids.new() as line: + line.product_id = butter + line.product_uom_id = uom_kg + line.product_qty = 5 + with bom.bom_line_ids.new() as line: + line.product_id = biscuit + line.product_uom_id = uom_kg + line.product_qty = 6 + with bom.operation_ids.new() as operation: + operation.workcenter_id = workcenter + operation.name = 'Prepare biscuits' + operation.time_cycle_manual = 5 + with bom.operation_ids.new() as operation: + operation.workcenter_id = workcenter + operation.name = 'Prepare butter' + operation.time_cycle_manual = 3 + with bom.operation_ids.new() as operation: + operation.workcenter_id = workcenter + operation.name = 'Mix manually' + operation.time_cycle_manual = 5 + + # TEST BOM STRUCTURE VALUE WITH BOM QUANTITY + report_values = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_crumble.id, searchQty=11, searchVariant=False) + # 5 min 'Prepare biscuits' + 3 min 'Prepare butter' + 5 min 'Mix manually' = 13 minutes + self.assertEqual(report_values['lines']['operations_time'], 13.0, 'Operation time should be the same for 1 unit or for the batch') + # Operation cost is the sum of operation line. + operation_cost = float_round(5 / 60 * 10, precision_digits=2) * 2 + float_round(3 / 60 * 10, precision_digits=2) + self.assertEqual(float_compare(report_values['lines']['operations_cost'], operation_cost, precision_digits=2), 0, '13 minute for 10$/hours -> 2.16') + + for component_line in report_values['lines']['components']: + # standard price * bom line quantity * current quantity / bom finished product quantity + if component_line['prod_id'] == butter.id: + # 5 kg of butter at 7.01$ for 11kg of crumble -> 35.05$ + self.assertEqual(float_compare(component_line['total'], (7.01 * 5), precision_digits=2), 0) + if component_line['prod_id'] == biscuit.id: + # 6 kg of biscuits at 1.50$ for 11kg of crumble -> 9$ + self.assertEqual(float_compare(component_line['total'], (1.5 * 6), precision_digits=2), 0) + # total price = 35.05 + 9 + operation_cost(0.83 + 0.83 + 0.5 = 2.16) = 46,21 + self.assertEqual(float_compare(report_values['lines']['total'], 46.21, precision_digits=2), 0, 'Product Bom Price is not correct') + self.assertEqual(float_compare(report_values['lines']['total'] / 11.0, 4.20, precision_digits=2), 0, 'Product Unit Bom Price is not correct') + + # TEST BOM STRUCTURE VALUE BY UNIT + report_values = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_crumble.id, searchQty=1, searchVariant=False) + # 5 min 'Prepare biscuits' + 3 min 'Prepare butter' + 5 min 'Mix manually' = 13 minutes + self.assertEqual(report_values['lines']['operations_time'], 13.0, 'Operation time should be the same for 1 unit or for the batch') + # Operation cost is the sum of operation line. + operation_cost = float_round(5 / 60 * 10, precision_digits=2) * 2 + float_round(3 / 60 * 10, precision_digits=2) + self.assertEqual(float_compare(report_values['lines']['operations_cost'], operation_cost, precision_digits=2), 0, '13 minute for 10$/hours -> 2.16') + + for component_line in report_values['lines']['components']: + # standard price * bom line quantity * current quantity / bom finished product quantity + if component_line['prod_id'] == butter.id: + # 5 kg of butter at 7.01$ for 11kg of crumble -> / 11 for price per unit (3.19) + self.assertEqual(float_compare(component_line['total'], (7.01 * 5) * (1 / 11), precision_digits=2), 0) + if component_line['prod_id'] == biscuit.id: + # 6 kg of biscuits at 1.50$ for 11kg of crumble -> / 11 for price per unit (0.82) + self.assertEqual(float_compare(component_line['total'], (1.5 * 6) * (1 / 11), precision_digits=2), 0) + # total price = 3.19 + 0.82 + operation_cost(0.83 + 0.83 + 0.5 = 2.16) = 6,17 + self.assertEqual(float_compare(report_values['lines']['total'], 6.17, precision_digits=2), 0, 'Product Unit Bom Price is not correct') + + # TEST OPERATION COST WHEN PRODUCED QTY > BOM QUANTITY + report_values_12 = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_crumble.id, searchQty=12, searchVariant=False) + report_values_22 = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_crumble.id, searchQty=22, searchVariant=False) + operation_cost = float_round(10 / 60 * 10, precision_digits=2) * 2 + float_round(6 / 60 * 10, precision_digits=2) + # Both needs 2 operation cycle + self.assertEqual(report_values_12['lines']['operations_cost'], report_values_22['lines']['operations_cost']) + self.assertEqual(report_values_22['lines']['operations_cost'], operation_cost) + report_values_23 = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_crumble.id, searchQty=23, searchVariant=False) + operation_cost = float_round(15 / 60 * 10, precision_digits=2) * 2 + float_round(9 / 60 * 10, precision_digits=2) + self.assertEqual(report_values_23['lines']['operations_cost'], operation_cost) + + # Create a more complex BoM with a sub product + cheese_cake = self.env['product.product'].create({ + 'name': 'Cheese Cake 300g', + 'type': 'product', + }) + cream = self.env['product.product'].create({ + 'name': 'cream', + 'type': 'product', + 'uom_id': uom_litre.id, + 'uom_po_id': uom_litre.id, + 'standard_price': 5.17, + }) + bom_form_cheese_cake = Form(self.env['mrp.bom']) + bom_form_cheese_cake.product_tmpl_id = cheese_cake.product_tmpl_id + bom_form_cheese_cake.product_qty = 60 + bom_form_cheese_cake.product_uom_id = self.uom_unit + bom_cheese_cake = bom_form_cheese_cake.save() + + workcenter_2 = self.env['mrp.workcenter'].create({ + 'name': 'cake mounting', + 'costs_hour': 20, + 'time_start': 10, + 'time_stop': 15 + }) + + with Form(bom_cheese_cake) as bom: + with bom.bom_line_ids.new() as line: + line.product_id = cream + line.product_uom_id = uom_litre + line.product_qty = 3 + with bom.bom_line_ids.new() as line: + line.product_id = crumble + line.product_uom_id = uom_kg + line.product_qty = 5.4 + with bom.operation_ids.new() as operation: + operation.workcenter_id = workcenter + operation.name = 'Mix cheese and crumble' + operation.time_cycle_manual = 10 + with bom.operation_ids.new() as operation: + operation.workcenter_id = workcenter_2 + operation.name = 'Cake mounting' + operation.time_cycle_manual = 5 + + + # TEST CHEESE BOM STRUCTURE VALUE WITH BOM QUANTITY + report_values = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_cheese_cake.id, searchQty=60, searchVariant=False) + self.assertEqual(report_values['lines']['operations_time'], 40.0, 'Operation time should be the same for 1 unit or for the batch') + # Operation cost is the sum of operation line. + operation_cost = float_round(10 / 60 * 10, precision_digits=2) + float_round(30 / 60 * 20, precision_digits=2) + self.assertEqual(float_compare(report_values['lines']['operations_cost'], operation_cost, precision_digits=2), 0) + + for component_line in report_values['lines']['components']: + # standard price * bom line quantity * current quantity / bom finished product quantity + if component_line['prod_id'] == cream.id: + # 3 liter of cream at 5.17$ for 60 unit of cheese cake -> 15.51$ + self.assertEqual(float_compare(component_line['total'], (3 * 5.17), precision_digits=2), 0) + if component_line['prod_id'] == crumble.id: + # 5.4 kg of crumble at the cost of a batch. + crumble_cost = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_crumble.id, searchQty=5.4, searchVariant=False)['lines']['total'] + self.assertEqual(float_compare(component_line['total'], crumble_cost, precision_digits=2), 0) + # total price = 15.51 + crumble_cost + operation_cost(10 + 1.67 = 11.67) = 27.18 + crumble_cost + self.assertEqual(float_compare(report_values['lines']['total'], 27.18 + crumble_cost, precision_digits=2), 0, 'Product Bom Price is not correct') + + def test_21_bom_report_variant(self): + """ Test a sub BoM process with multiple variants. + BOM 1: + product template = car + quantity = 5 units + - red paint 50l -> red car (product.product) + - blue paint 50l -> blue car + - red dashboard with gps -> red car with GPS + - red dashboard w/h gps -> red w/h GPS + - blue dashboard with gps -> blue car with GPS + - blue dashboard w/h gps -> blue w/h GPS + + BOM 2: + product_tmpl = dashboard + quantity = 2 + - red paint 1l -> red dashboard (product.product) + - blue paint 1l -> blue dashboard + - gps -> dashboard with gps + + Check the Price for a Blue Car with GPS -> 910$: + 10l of blue paint -> 200$ + 1 blue dashboard GPS -> 710$: + - 0.5l of blue paint -> 10$ + - GPS -> 700$ + + Check the price for a red car -> 10.5l of red paint -> 210$ + """ + # Create a product template car with attributes gps(yes, no), color(red, blue) + self.car = self.env['product.template'].create({ + 'name': 'Car', + }) + self.gps_attribute = self.env['product.attribute'].create({'name': 'GPS', 'sequence': 1}) + self.gps_yes = self.env['product.attribute.value'].create({ + 'name': 'Yes', + 'attribute_id': self.gps_attribute.id, + 'sequence': 1, + }) + self.gps_no = self.env['product.attribute.value'].create({ + 'name': 'No', + 'attribute_id': self.gps_attribute.id, + 'sequence': 2, + }) + + self.car_gps_attribute_line = self.env['product.template.attribute.line'].create({ + 'product_tmpl_id': self.car.id, + 'attribute_id': self.gps_attribute.id, + 'value_ids': [(6, 0, [self.gps_yes.id, self.gps_no.id])], + }) + self.car_gps_yes = self.car_gps_attribute_line.product_template_value_ids[0] + self.car_gps_no = self.car_gps_attribute_line.product_template_value_ids[1] + + self.color_attribute = self.env['product.attribute'].create({'name': 'Color', 'sequence': 1}) + self.color_red = self.env['product.attribute.value'].create({ + 'name': 'Red', + 'attribute_id': self.color_attribute.id, + 'sequence': 1, + }) + self.color_blue = self.env['product.attribute.value'].create({ + 'name': 'Blue', + 'attribute_id': self.color_attribute.id, + 'sequence': 2, + }) + + self.car_color_attribute_line = self.env['product.template.attribute.line'].create({ + 'product_tmpl_id': self.car.id, + 'attribute_id': self.color_attribute.id, + 'value_ids': [(6, 0, [self.color_red.id, self.color_blue.id])], + }) + self.car_color_red = self.car_color_attribute_line.product_template_value_ids[0] + self.car_color_blue = self.car_color_attribute_line.product_template_value_ids[1] + + # Blue and red paint + uom_litre = self.env.ref('uom.product_uom_litre') + self.paint = self.env['product.template'].create({ + 'name': 'Paint', + 'uom_id': uom_litre.id, + 'uom_po_id': uom_litre.id + }) + self.paint_color_attribute_line = self.env['product.template.attribute.line'].create({ + 'product_tmpl_id': self.paint.id, + 'attribute_id': self.color_attribute.id, + 'value_ids': [(6, 0, [self.color_red.id, self.color_blue.id])], + }) + self.paint_color_red = self.paint_color_attribute_line.product_template_value_ids[0] + self.paint_color_blue = self.paint_color_attribute_line.product_template_value_ids[1] + + self.paint.product_variant_ids.write({'standard_price': 20}) + + self.dashboard = self.env['product.template'].create({ + 'name': 'Dashboard', + 'standard_price': 1000, + }) + + self.dashboard_gps_attribute_line = self.env['product.template.attribute.line'].create({ + 'product_tmpl_id': self.dashboard.id, + 'attribute_id': self.gps_attribute.id, + 'value_ids': [(6, 0, [self.gps_yes.id, self.gps_no.id])], + }) + self.dashboard_gps_yes = self.dashboard_gps_attribute_line.product_template_value_ids[0] + self.dashboard_gps_no = self.dashboard_gps_attribute_line.product_template_value_ids[1] + + self.dashboard_color_attribute_line = self.env['product.template.attribute.line'].create({ + 'product_tmpl_id': self.dashboard.id, + 'attribute_id': self.color_attribute.id, + 'value_ids': [(6, 0, [self.color_red.id, self.color_blue.id])], + }) + self.dashboard_color_red = self.dashboard_color_attribute_line.product_template_value_ids[0] + self.dashboard_color_blue = self.dashboard_color_attribute_line.product_template_value_ids[1] + + self.gps = self.env['product.product'].create({ + 'name': 'GPS', + 'standard_price': 700, + }) + + bom_form_car = Form(self.env['mrp.bom']) + bom_form_car.product_tmpl_id = self.car + bom_form_car.product_qty = 5 + with bom_form_car.bom_line_ids.new() as line: + line.product_id = self.paint._get_variant_for_combination(self.paint_color_red) + line.product_uom_id = uom_litre + line.product_qty = 50 + line.bom_product_template_attribute_value_ids.add(self.car_color_red) + with bom_form_car.bom_line_ids.new() as line: + line.product_id = self.paint._get_variant_for_combination(self.paint_color_blue) + line.product_uom_id = uom_litre + line.product_qty = 50 + line.bom_product_template_attribute_value_ids.add(self.car_color_blue) + with bom_form_car.bom_line_ids.new() as line: + line.product_id = self.dashboard._get_variant_for_combination(self.dashboard_gps_yes + self.dashboard_color_red) + line.product_qty = 5 + line.bom_product_template_attribute_value_ids.add(self.car_gps_yes) + line.bom_product_template_attribute_value_ids.add(self.car_color_red) + with bom_form_car.bom_line_ids.new() as line: + line.product_id = self.dashboard._get_variant_for_combination(self.dashboard_gps_yes + self.dashboard_color_blue) + line.product_qty = 5 + line.bom_product_template_attribute_value_ids.add(self.car_gps_yes) + line.bom_product_template_attribute_value_ids.add(self.car_color_blue) + with bom_form_car.bom_line_ids.new() as line: + line.product_id = self.dashboard._get_variant_for_combination(self.dashboard_gps_no + self.dashboard_color_red) + line.product_qty = 5 + line.bom_product_template_attribute_value_ids.add(self.car_gps_no) + line.bom_product_template_attribute_value_ids.add(self.car_color_red) + with bom_form_car.bom_line_ids.new() as line: + line.product_id = self.dashboard._get_variant_for_combination(self.dashboard_gps_no + self.dashboard_color_blue) + line.product_qty = 5 + line.bom_product_template_attribute_value_ids.add(self.car_gps_no) + line.bom_product_template_attribute_value_ids.add(self.car_color_blue) + bom_car = bom_form_car.save() + + bom_dashboard = Form(self.env['mrp.bom']) + bom_dashboard.product_tmpl_id = self.dashboard + bom_dashboard.product_qty = 2 + with bom_dashboard.bom_line_ids.new() as line: + line.product_id = self.paint._get_variant_for_combination(self.paint_color_red) + line.product_uom_id = uom_litre + line.product_qty = 1 + line.bom_product_template_attribute_value_ids.add(self.dashboard_color_red) + with bom_dashboard.bom_line_ids.new() as line: + line.product_id = self.paint._get_variant_for_combination(self.paint_color_blue) + line.product_uom_id = uom_litre + line.product_qty = 1 + line.bom_product_template_attribute_value_ids.add(self.dashboard_color_blue) + with bom_dashboard.bom_line_ids.new() as line: + line.product_id = self.gps + line.product_qty = 2 + line.bom_product_template_attribute_value_ids.add(self.dashboard_gps_yes) + bom_dashboard = bom_dashboard.save() + + blue_car_with_gps = self.car._get_variant_for_combination(self.car_color_blue + self.car_gps_yes) + + report_values = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_car.id, searchQty=1, searchVariant=blue_car_with_gps.id) + # Two lines. blue dashboard with gps and blue paint. + self.assertEqual(len(report_values['lines']['components']), 2) + + # 10l of blue paint + blue_paint = self.paint._get_variant_for_combination(self.paint_color_blue) + self.assertEqual(blue_paint.id, report_values['lines']['components'][0]['prod_id']) + self.assertEqual(report_values['lines']['components'][0]['prod_qty'], 10) + # 1 blue dashboard with GPS + blue_dashboard_gps = self.dashboard._get_variant_for_combination(self.dashboard_color_blue + self.dashboard_gps_yes) + self.assertEqual(blue_dashboard_gps.id, report_values['lines']['components'][1]['prod_id']) + self.assertEqual(report_values['lines']['components'][1]['prod_qty'], 1) + component = report_values['lines']['components'][1] + report_values_dashboad = self.env['report.mrp.report_bom_structure']._get_bom( + component['child_bom'], component['prod_id'], component['prod_qty'], + component['line_id'], component['level'] + 1) + + self.assertEqual(len(report_values_dashboad['components']), 2) + self.assertEqual(blue_paint.id, report_values_dashboad['components'][0]['prod_id']) + self.assertEqual(self.gps.id, report_values_dashboad['components'][1]['prod_id']) + + # 0.5l of paint at price of 20$/litre -> 10$ + self.assertEqual(report_values_dashboad['components'][0]['total'], 10) + # GPS 700$ + self.assertEqual(report_values_dashboad['components'][1]['total'], 700) + + # Dashboard blue with GPS should have a BoM cost of 710$ + self.assertEqual(report_values['lines']['components'][1]['total'], 710) + # 10l of paint at price of 20$/litre -> 200$ + self.assertEqual(report_values['lines']['components'][0]['total'], 200) + + # Total cost of blue car with GPS: 10 + 700 + 200 = 910 + self.assertEqual(report_values['lines']['total'], 910) + + red_car_without_gps = self.car._get_variant_for_combination(self.car_color_red + self.car_gps_no) + + report_values = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_car.id, searchQty=1, searchVariant=red_car_without_gps.id) + # Same math than before but without GPS + self.assertEqual(report_values['lines']['total'], 210) + + def test_22_bom_report_recursive_bom(self): + """ Test report with recursive BoM and different quantities. + BoM 1: + product = Finished (units) + quantity = 100 units + - Semi-Finished 5 kg + + BoM 2: + product = Semi-Finished (kg) + quantity = 11 kg + - Assembly 2 dozens + + BoM 3: + product = Assembly (dozens) + quantity = 5 dozens + - Raw Material 4 litres (product.product 5$/litre) + + Check the Price for 80 units of Finished -> 2.92$: + """ + # Create a products templates + uom_unit = self.env.ref('uom.product_uom_unit') + uom_kg = self.env.ref('uom.product_uom_kgm') + uom_dozen = self.env.ref('uom.product_uom_dozen') + uom_litre = self.env.ref('uom.product_uom_litre') + + finished = self.env['product.product'].create({ + 'name': 'Finished', + 'type': 'product', + 'uom_id': uom_unit.id, + 'uom_po_id': uom_unit.id, + }) + + semi_finished = self.env['product.product'].create({ + 'name': 'Semi-Finished', + 'type': 'product', + 'uom_id': uom_kg.id, + 'uom_po_id': uom_kg.id, + }) + + assembly = self.env['product.product'].create({ + 'name': 'Assembly', + 'type': 'product', + 'uom_id': uom_dozen.id, + 'uom_po_id': uom_dozen.id, + }) + + raw_material = self.env['product.product'].create({ + 'name': 'Raw Material', + 'type': 'product', + 'uom_id': uom_litre.id, + 'uom_po_id': uom_litre.id, + 'standard_price': 5, + }) + + #Create bom + bom_finished = Form(self.env['mrp.bom']) + bom_finished.product_tmpl_id = finished.product_tmpl_id + bom_finished.product_qty = 100 + with bom_finished.bom_line_ids.new() as line: + line.product_id = semi_finished + line.product_uom_id = uom_kg + line.product_qty = 5 + bom_finished = bom_finished.save() + + bom_semi_finished = Form(self.env['mrp.bom']) + bom_semi_finished.product_tmpl_id = semi_finished.product_tmpl_id + bom_semi_finished.product_qty = 11 + with bom_semi_finished.bom_line_ids.new() as line: + line.product_id = assembly + line.product_uom_id = uom_dozen + line.product_qty = 2 + bom_semi_finished = bom_semi_finished.save() + + bom_assembly = Form(self.env['mrp.bom']) + bom_assembly.product_tmpl_id = assembly.product_tmpl_id + bom_assembly.product_qty = 5 + with bom_assembly.bom_line_ids.new() as line: + line.product_id = raw_material + line.product_uom_id = uom_litre + line.product_qty = 4 + bom_assembly = bom_assembly.save() + + report_values = self.env['report.mrp.report_bom_structure']._get_report_data(bom_id=bom_finished.id, searchQty=80) + + self.assertAlmostEqual(report_values['lines']['total'], 2.92) + + def test_validate_no_bom_line_with_same_product(self): + """ + Cannot set a BOM line on a BOM with the same product as the BOM itself + """ + uom_unit = self.env.ref('uom.product_uom_unit') + finished = self.env['product.product'].create({ + 'name': 'Finished', + 'type': 'product', + 'uom_id': uom_unit.id, + 'uom_po_id': uom_unit.id, + }) + bom_finished = Form(self.env['mrp.bom']) + bom_finished.product_tmpl_id = finished.product_tmpl_id + bom_finished.product_qty = 100 + with bom_finished.bom_line_ids.new() as line: + line.product_id = finished + line.product_uom_id = uom_unit + line.product_qty = 5 + with self.assertRaises(exceptions.ValidationError), self.cr.savepoint(): + bom_finished = bom_finished.save() + + def test_validate_no_bom_line_with_same_product_variant(self): + """ + Cannot set a BOM line on a BOM with the same product variant as the BOM itself + """ + uom_unit = self.env.ref('uom.product_uom_unit') + bom_finished = Form(self.env['mrp.bom']) + bom_finished.product_tmpl_id = self.product_7_template + bom_finished.product_id = self.product_7_3 + bom_finished.product_qty = 100 + with bom_finished.bom_line_ids.new() as line: + line.product_id = self.product_7_3 + line.product_uom_id = uom_unit + 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) + Usecase for example A black T-shirt made from a white T-shirt and + black color. + """ + uom_unit = self.env.ref('uom.product_uom_unit') + bom_finished = Form(self.env['mrp.bom']) + bom_finished.product_tmpl_id = self.product_7_template + bom_finished.product_id = self.product_7_3 + bom_finished.product_qty = 100 + with bom_finished.bom_line_ids.new() as line: + line.product_id = self.product_7_2 + line.product_uom_id = uom_unit + line.product_qty = 5 + bom_finished = bom_finished.save() + + def test_validate_bom_line_with_variant_of_bom_product(self): + """ + Can set a BOM line on a BOM with a product variant when the BOM has no variant selected + """ + uom_unit = self.env.ref('uom.product_uom_unit') + bom_finished = Form(self.env['mrp.bom']) + bom_finished.product_tmpl_id = self.product_6.product_tmpl_id + # no product_id + bom_finished.product_qty = 100 + with bom_finished.bom_line_ids.new() as line: + line.product_id = self.product_7_2 + line.product_uom_id = uom_unit + line.product_qty = 5 + bom_finished = bom_finished.save() diff --git a/addons/mrp/tests/test_byproduct.py b/addons/mrp/tests/test_byproduct.py new file mode 100644 index 00000000..0249e2cc --- /dev/null +++ b/addons/mrp/tests/test_byproduct.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import Form +from odoo.tests import common + + +class TestMrpByProduct(common.TransactionCase): + + def setUp(self): + super(TestMrpByProduct, self).setUp() + self.MrpBom = self.env['mrp.bom'] + self.warehouse = self.env.ref('stock.warehouse0') + route_manufacture = self.warehouse.manufacture_pull_id.route_id.id + route_mto = self.warehouse.mto_pull_id.route_id.id + self.uom_unit_id = self.ref('uom.product_uom_unit') + def create_product(name, route_ids=[]): + return self.env['product.product'].create({ + 'name': name, + 'type': 'product', + 'route_ids': route_ids}) + + # Create product A, B, C. + # -------------------------- + self.product_a = create_product('Product A', route_ids=[(6, 0, [route_manufacture, route_mto])]) + self.product_b = create_product('Product B', route_ids=[(6, 0, [route_manufacture, route_mto])]) + self.product_c_id = create_product('Product C', route_ids=[]).id + + def test_00_mrp_byproduct(self): + """ Test by product with production order.""" + # Create BOM for product B + # ------------------------ + bom_product_b = self.MrpBom.create({ + 'product_tmpl_id': self.product_b.product_tmpl_id.id, + 'product_qty': 1.0, + 'type': 'normal', + 'product_uom_id': self.uom_unit_id, + 'bom_line_ids': [(0, 0, {'product_id': self.product_c_id, 'product_uom_id': self.uom_unit_id, 'product_qty': 2})] + }) + + # Create BOM for product A and set byproduct product B + bom_product_a = self.MrpBom.create({ + 'product_tmpl_id': self.product_a.product_tmpl_id.id, + 'product_qty': 1.0, + 'type': 'normal', + 'product_uom_id': self.uom_unit_id, + 'bom_line_ids': [(0, 0, {'product_id': self.product_c_id, 'product_uom_id': self.uom_unit_id, 'product_qty': 2})], + 'byproduct_ids': [(0, 0, {'product_id': self.product_b.id, 'product_uom_id': self.uom_unit_id, 'product_qty': 1})] + }) + + # Create production order for product A + # ------------------------------------- + + mnf_product_a_form = Form(self.env['mrp.production']) + mnf_product_a_form.product_id = self.product_a + mnf_product_a_form.bom_id = bom_product_a + mnf_product_a_form.product_qty = 2.0 + mnf_product_a = mnf_product_a_form.save() + mnf_product_a.action_confirm() + + # I confirm the production order. + self.assertEqual(mnf_product_a.state, 'confirmed', 'Production order should be in state confirmed') + + # Now I check the stock moves for the byproduct I created in the bill of material. + # This move is created automatically when I confirmed the production order. + moves = mnf_product_a.move_raw_ids | mnf_product_a.move_finished_ids + self.assertTrue(moves, 'No moves are created !') + + # I consume and produce the production of products. + # I create record for selecting mode and quantity of products to produce. + mo_form = Form(mnf_product_a) + mo_form.qty_producing = 2.00 + mnf_product_a = mo_form.save() + # I finish the production order. + self.assertEqual(len(mnf_product_a.move_raw_ids), 1, "Wrong consume move on production order.") + consume_move_c = mnf_product_a.move_raw_ids + by_product_move = mnf_product_a.move_finished_ids.filtered(lambda x: x.product_id.id == self.product_b.id) + # Check sub production produced quantity... + self.assertEqual(consume_move_c.product_uom_qty, 4, "Wrong consumed quantity of product c.") + self.assertEqual(by_product_move.product_uom_qty, 2, "Wrong produced quantity of sub product.") + + mnf_product_a._post_inventory() + + # I see that stock moves of External Hard Disk including Headset USB are done now. + self.assertFalse(any(move.state != 'done' for move in moves), 'Moves are not done!') + + def test_change_product(self): + """ Create a production order for a specific product with a BoM. Then change the BoM and the finished product for + other ones and check the finished product of the first mo did not became a byproduct of the second one.""" + # Create BOM for product A with product B as component + bom_product_a = self.MrpBom.create({ + 'product_tmpl_id': self.product_a.product_tmpl_id.id, + 'product_qty': 1.0, + 'type': 'normal', + 'product_uom_id': self.uom_unit_id, + 'bom_line_ids': [(0, 0, {'product_id': self.product_b.id, 'product_uom_id': self.uom_unit_id, 'product_qty': 2})], + }) + + bom_product_a_2 = self.MrpBom.create({ + 'product_tmpl_id': self.product_b.product_tmpl_id.id, + 'product_qty': 1.0, + 'type': 'normal', + 'product_uom_id': self.uom_unit_id, + 'bom_line_ids': [(0, 0, {'product_id': self.product_c_id, 'product_uom_id': self.uom_unit_id, 'product_qty': 2})], + }) + # Create production order for product A + # ------------------------------------- + + mnf_product_a_form = Form(self.env['mrp.production']) + mnf_product_a_form.product_id = self.product_a + mnf_product_a_form.bom_id = bom_product_a + mnf_product_a_form.product_qty = 1.0 + mnf_product_a = mnf_product_a_form.save() + mnf_product_a_form = Form(mnf_product_a) + mnf_product_a_form.bom_id = bom_product_a_2 + mnf_product_a = mnf_product_a_form.save() + self.assertEqual(mnf_product_a.move_raw_ids.product_id.id, self.product_c_id) + self.assertFalse(mnf_product_a.move_byproduct_ids) diff --git a/addons/mrp/tests/test_cancel_mo.py b/addons/mrp/tests/test_cancel_mo.py new file mode 100644 index 00000000..d305571c --- /dev/null +++ b/addons/mrp/tests/test_cancel_mo.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import Form +from datetime import datetime, timedelta + +from odoo.fields import Datetime as Dt +from odoo.exceptions import UserError +from odoo.addons.mrp.tests.common import TestMrpCommon + + +class TestMrpCancelMO(TestMrpCommon): + + def test_cancel_mo_without_routing_1(self): + """ Cancel a Manufacturing Order with no routing, no production. + """ + # Create MO + manufacturing_order = self.generate_mo()[0] + # Do nothing, cancel it + manufacturing_order.action_cancel() + # Check the MO and its moves are cancelled + self.assertEqual(manufacturing_order.state, 'cancel', "MO should be in cancel state.") + self.assertEqual(manufacturing_order.move_raw_ids[0].state, 'cancel', + "Cancelled MO raw moves must be cancelled as well.") + self.assertEqual(manufacturing_order.move_raw_ids[1].state, 'cancel', + "Cancelled MO raw moves must be cancelled as well.") + self.assertEqual(manufacturing_order.move_finished_ids.state, 'cancel', + "Cancelled MO finished move must be cancelled as well.") + + def test_cancel_mo_without_routing_2(self): + """ Cancel a Manufacturing Order with no routing but some productions. + """ + # Create MO + manufacturing_order = self.generate_mo()[0] + # Produce some quantity + mo_form = Form(manufacturing_order) + mo_form.qty_producing = 2 + manufacturing_order = mo_form.save() + # Cancel it + manufacturing_order.action_cancel() + # Check it's cancelled + self.assertEqual(manufacturing_order.state, 'cancel', "MO should be in cancel state.") + self.assertEqual(manufacturing_order.move_raw_ids[0].state, 'cancel', + "Cancelled MO raw moves must be cancelled as well.") + self.assertEqual(manufacturing_order.move_raw_ids[1].state, 'cancel', + "Cancelled MO raw moves must be cancelled as well.") + self.assertEqual(manufacturing_order.move_finished_ids.state, 'cancel', + "Cancelled MO finished move must be cancelled as well.") + + def test_cancel_mo_without_routing_3(self): + """ Cancel a Manufacturing Order with no routing but some productions + after post inventory. + """ + # Create MO + manufacturing_order = self.generate_mo(consumption='strict')[0] + # Produce some quantity (not all to avoid to done the MO when post inventory) + mo_form = Form(manufacturing_order) + mo_form.qty_producing = 2 + manufacturing_order = mo_form.save() + # Post Inventory + manufacturing_order._post_inventory() + # Cancel the MO + manufacturing_order.action_cancel() + # Check MO is marked as done and its SML are done or cancelled + self.assertEqual(manufacturing_order.state, 'done', "MO should be in done state.") + self.assertEqual(manufacturing_order.move_raw_ids[0].state, 'done', + "Due to 'post_inventory', some move raw must stay in done state") + self.assertEqual(manufacturing_order.move_raw_ids[1].state, 'done', + "Due to 'post_inventory', some move raw must stay in done state") + self.assertEqual(manufacturing_order.move_raw_ids[2].state, 'cancel', + "The other move raw are cancelled like their MO.") + self.assertEqual(manufacturing_order.move_raw_ids[3].state, 'cancel', + "The other move raw are cancelled like their MO.") + self.assertEqual(manufacturing_order.move_finished_ids[0].state, 'done', + "Due to 'post_inventory', a move finished must stay in done state") + self.assertEqual(manufacturing_order.move_finished_ids[1].state, 'cancel', + "The other move finished is cancelled like its MO.") + + def test_unlink_mo(self): + """ Try to unlink a Manufacturing Order, and check it's possible or not + depending of the MO state (must be in cancel state to be unlinked, but + the unlink method will try to cancel MO before unlink them). + """ + # Case #1: Create MO, do nothing and try to unlink it (can be deleted) + manufacturing_order = self.generate_mo()[0] + self.assertEqual(manufacturing_order.exists().state, 'confirmed') + manufacturing_order.unlink() + # Check the MO is deleted. + self.assertEqual(manufacturing_order.exists().state, False) + + # Case #2: Create MO, make and post some production, then try to unlink + # it (cannot be deleted) + manufacturing_order = self.generate_mo()[0] + # Produce some quantity (not all to avoid to done the MO when post inventory) + mo_form = Form(manufacturing_order) + mo_form.qty_producing = 2 + manufacturing_order = mo_form.save() + # Post Inventory + manufacturing_order._post_inventory() + # Unlink the MO must raises an UserError since it cannot be really cancelled + self.assertEqual(manufacturing_order.exists().state, 'progress') + with self.assertRaises(UserError): + manufacturing_order.unlink() diff --git a/addons/mrp/tests/test_multicompany.py b/addons/mrp/tests/test_multicompany.py new file mode 100644 index 00000000..6b0c3fd3 --- /dev/null +++ b/addons/mrp/tests/test_multicompany.py @@ -0,0 +1,187 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import common, Form +from odoo.exceptions import UserError + + +class TestMrpMulticompany(common.TransactionCase): + + def setUp(self): + super(TestMrpMulticompany, self).setUp() + + group_user = self.env.ref('base.group_user') + group_mrp_manager = self.env.ref('mrp.group_mrp_manager') + self.company_a = self.env['res.company'].create({'name': 'Company A'}) + self.company_b = self.env['res.company'].create({'name': 'Company B'}) + self.warehouse_a = self.env['stock.warehouse'].search([('company_id', '=', self.company_a.id)], limit=1) + self.warehouse_b = self.env['stock.warehouse'].search([('company_id', '=', self.company_b.id)], limit=1) + self.stock_location_a = self.warehouse_a.lot_stock_id + self.stock_location_b = self.warehouse_b.lot_stock_id + + self.user_a = self.env['res.users'].create({ + 'name': 'user company a with access to company b', + 'login': 'user a', + 'groups_id': [(6, 0, [group_user.id, group_mrp_manager.id])], + 'company_id': self.company_a.id, + 'company_ids': [(6, 0, [self.company_a.id, self.company_b.id])] + }) + self.user_b = self.env['res.users'].create({ + 'name': 'user company a with access to company b', + 'login': 'user b', + 'groups_id': [(6, 0, [group_user.id, group_mrp_manager.id])], + 'company_id': self.company_b.id, + 'company_ids': [(6, 0, [self.company_a.id, self.company_b.id])] + }) + + def test_bom_1(self): + """Check it is not possible to use a product of Company B in a + bom of Company A. """ + + product_b = self.env['product.product'].create({ + 'name': 'p1', + 'company_id': self.company_b.id, + }) + with self.assertRaises(UserError): + self.env['mrp.bom'].create({ + 'product_id': product_b.id, + 'product_tmpl_id': product_b.product_tmpl_id.id, + 'company_id': self.company_a.id, + }) + + def test_bom_2(self): + """Check it is not possible to use a product of Company B as a component + in a bom of Company A. """ + + product_a = self.env['product.product'].create({ + 'name': 'p1', + 'company_id': self.company_a.id, + }) + product_b = self.env['product.product'].create({ + 'name': 'p2', + 'company_id': self.company_b.id, + }) + with self.assertRaises(UserError): + self.env['mrp.bom'].create({ + 'product_id': product_a.id, + 'product_tmpl_id': product_b.product_tmpl_id.id, + 'company_id': self.company_a.id, + 'bom_line_ids': [(0, 0, {'product_id': product_b.id})] + }) + + def test_production_1(self): + """Check it is not possible to confirm a production of Company B with + product of Company A. """ + + product_a = self.env['product.product'].create({ + 'name': 'p1', + 'company_id': self.company_a.id, + }) + mo = self.env['mrp.production'].create({ + 'product_id': product_a.id, + 'product_uom_id': product_a.uom_id.id, + 'company_id': self.company_b.id, + }) + with self.assertRaises(UserError): + mo.action_confirm() + + def test_production_2(self): + """Check that confirming a production in company b with user_a will create + stock moves on company b. """ + + product_a = self.env['product.product'].create({ + 'name': 'p1', + 'company_id': self.company_a.id, + }) + component_a = self.env['product.product'].create({ + 'name': 'p2', + 'company_id': self.company_a.id, + }) + self.env['mrp.bom'].create({ + 'product_id': product_a.id, + 'product_tmpl_id': product_a.product_tmpl_id.id, + 'company_id': self.company_a.id, + 'bom_line_ids': [(0, 0, {'product_id': component_a.id})] + }) + mo_form = Form(self.env['mrp.production'].with_user(self.user_a)) + mo_form.product_id = product_a + mo = mo_form.save() + mo.with_user(self.user_b).action_confirm() + self.assertEqual(mo.move_raw_ids.company_id, self.company_a) + self.assertEqual(mo.move_finished_ids.company_id, self.company_a) + + def test_product_produce_1(self): + """Check that using a finished lot of company b in the produce wizard of a production + of company a is not allowed """ + + product = self.env['product.product'].create({ + 'name': 'p1', + 'tracking': 'lot', + }) + component = self.env['product.product'].create({ + 'name': 'p2', + }) + lot_b = self.env['stock.production.lot'].create({ + 'product_id': product.id, + 'company_id': self.company_b.id, + }) + self.env['mrp.bom'].create({ + 'product_id': product.id, + 'product_tmpl_id': product.product_tmpl_id.id, + 'company_id': self.company_a.id, + 'bom_line_ids': [(0, 0, {'product_id': component.id})] + }) + mo_form = Form(self.env['mrp.production'].with_user(self.user_a)) + mo_form.product_id = product + mo_form.lot_producing_id = lot_b + mo = mo_form.save() + with self.assertRaises(UserError): + mo.with_user(self.user_b).action_confirm() + + def test_product_produce_2(self): + """Check that using a component lot of company b in the produce wizard of a production + of company a is not allowed """ + + product = self.env['product.product'].create({ + 'name': 'p1', + }) + component = self.env['product.product'].create({ + 'name': 'p2', + 'tracking': 'lot', + }) + lot_b = self.env['stock.production.lot'].create({ + 'product_id': component.id, + 'company_id': self.company_b.id, + }) + self.env['mrp.bom'].create({ + 'product_id': product.id, + 'product_tmpl_id': product.product_tmpl_id.id, + 'company_id': self.company_a.id, + 'bom_line_ids': [(0, 0, {'product_id': component.id})] + }) + mo_form = Form(self.env['mrp.production'].with_user(self.user_a)) + mo_form.product_id = product + mo = mo_form.save() + mo.with_user(self.user_b).action_confirm() + mo_form = Form(mo) + mo_form.qty_producing = 1 + mo = mo_form.save() + details_operation_form = Form(mo.move_raw_ids[0], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.edit(0) as ml: + ml.lot_id = lot_b + ml.qty_done = 1 + details_operation_form.save() + with self.assertRaises(UserError): + mo.button_mark_done() + + + def test_partner_1(self): + """ On a product without company, as a user of Company B, check it is not possible to use a + location limited to Company A as `property_stock_production` """ + + shared_product = self.env['product.product'].create({ + 'name': 'Shared Product', + 'company_id': False, + }) + with self.assertRaises(UserError): + shared_product.with_user(self.user_b).property_stock_production = self.stock_location_a diff --git a/addons/mrp/tests/test_oee.py b/addons/mrp/tests/test_oee.py new file mode 100644 index 00000000..4cf8a31a --- /dev/null +++ b/addons/mrp/tests/test_oee.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from datetime import datetime, timedelta, time +from pytz import timezone, utc + +from odoo import fields +from odoo.addons.mrp.tests.common import TestMrpCommon + + +class TestOee(TestMrpCommon): + def create_productivity_line(self, loss_reason, date_start=False, date_end=False): + return self.env['mrp.workcenter.productivity'].create({ + 'workcenter_id': self.workcenter_1.id, + 'date_start': date_start, + 'date_end': date_end, + 'loss_id': loss_reason.id, + 'description': loss_reason.name + }) + + def test_wrokcenter_oee(self): + """ Test case workcenter oee. """ + day = datetime.date(datetime.today()) + # Make the test work the weekend. It will fails due to workcenter working hours. + if day.weekday() in (5, 6): + day -= timedelta(days=2) + + tz = timezone(self.workcenter_1.resource_calendar_id.tz) + + def time_to_string_utc_datetime(time): + return fields.Datetime.to_string( + tz.localize(datetime.combine(day, time)).astimezone(utc) + ) + + start_time = time_to_string_utc_datetime(time(10, 43, 22)) + end_time = time_to_string_utc_datetime(time(10, 56, 22)) + # Productive time duration (13 min) + self.create_productivity_line(self.env.ref('mrp.block_reason7'), start_time, end_time) + + # Material Availability time duration (1.52 min) + # Check working state is blocked or not. + start_time = time_to_string_utc_datetime(time(10, 47, 8)) + workcenter_productivity_1 = self.create_productivity_line(self.env.ref('mrp.block_reason0'), start_time) + self.assertEqual(self.workcenter_1.working_state, 'blocked', "Wrong working state of workcenter.") + + # Check working state is normal or not. + end_time = time_to_string_utc_datetime(time(10, 48, 39)) + workcenter_productivity_1.write({'date_end': end_time}) + self.assertEqual(self.workcenter_1.working_state, 'normal', "Wrong working state of workcenter.") + + # Process Defect time duration (1.33 min) + start_time = time_to_string_utc_datetime(time(10, 48, 38)) + end_time = time_to_string_utc_datetime(time(10, 49, 58)) + self.create_productivity_line(self.env.ref('mrp.block_reason5'), start_time, end_time) + # Reduced Speed time duration (3.0 min) + start_time = time_to_string_utc_datetime(time(10, 50, 22)) + end_time = time_to_string_utc_datetime(time(10, 53, 22)) + self.create_productivity_line(self.env.ref('mrp.block_reason4'), start_time, end_time) + + # Block time : ( Process Defact (1.33 min) + Reduced Speed (3.0 min) + Material Availability (1.52 min)) = 5.85 min + blocked_time_in_hour = round(((1.33 + 3.0 + 1.52) / 60.0), 2) + # Productive time : Productive time duration (13 min) + productive_time_in_hour = round((13.0 / 60.0), 2) + + # Check blocked time and productive time + self.assertEqual(self.workcenter_1.blocked_time, blocked_time_in_hour, "Wrong block time on workcenter.") + self.assertEqual(self.workcenter_1.productive_time, productive_time_in_hour, "Wrong productive time on workcenter.") + + # Check overall equipment effectiveness + computed_oee = round(((productive_time_in_hour * 100.0)/(productive_time_in_hour + blocked_time_in_hour)), 2) + self.assertEqual(self.workcenter_1.oee, computed_oee, "Wrong oee on workcenter.") diff --git a/addons/mrp/tests/test_order.py b/addons/mrp/tests/test_order.py new file mode 100644 index 00000000..f0bae429 --- /dev/null +++ b/addons/mrp/tests/test_order.py @@ -0,0 +1,2055 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import Form +from datetime import datetime, timedelta + +from odoo.fields import Datetime as Dt +from odoo.exceptions import UserError +from odoo.addons.mrp.tests.common import TestMrpCommon + +class TestMrpOrder(TestMrpCommon): + + def test_access_rights_manager(self): + """ Checks an MRP manager can create, confirm and cancel a manufacturing order. """ + man_order_form = Form(self.env['mrp.production'].with_user(self.user_mrp_manager)) + man_order_form.product_id = self.product_4 + man_order_form.product_qty = 5.0 + man_order_form.bom_id = self.bom_1 + man_order_form.location_src_id = self.location_1 + man_order_form.location_dest_id = self.warehouse_1.wh_output_stock_loc_id + man_order = man_order_form.save() + man_order.action_confirm() + man_order.action_cancel() + self.assertEqual(man_order.state, 'cancel', "Production order should be in cancel state.") + man_order.unlink() + + def test_access_rights_user(self): + """ Checks an MRP user can create, confirm and cancel a manufacturing order. """ + man_order_form = Form(self.env['mrp.production'].with_user(self.user_mrp_user)) + man_order_form.product_id = self.product_4 + man_order_form.product_qty = 5.0 + man_order_form.bom_id = self.bom_1 + man_order_form.location_src_id = self.location_1 + man_order_form.location_dest_id = self.warehouse_1.wh_output_stock_loc_id + man_order = man_order_form.save() + man_order.action_confirm() + man_order.action_cancel() + self.assertEqual(man_order.state, 'cancel', "Production order should be in cancel state.") + man_order.unlink() + + def test_basic(self): + """ Checks a basic manufacturing order: no routing (thus no workorders), no lot and + consume strictly what's needed. """ + self.product_1.type = 'product' + self.product_2.type = 'product' + inventory = self.env['stock.inventory'].create({ + 'name': 'Initial inventory', + 'line_ids': [(0, 0, { + 'product_id': self.product_1.id, + 'product_uom_id': self.product_1.uom_id.id, + 'product_qty': 500, + 'location_id': self.warehouse_1.lot_stock_id.id + }), (0, 0, { + 'product_id': self.product_2.id, + 'product_uom_id': self.product_2.uom_id.id, + 'product_qty': 500, + 'location_id': self.warehouse_1.lot_stock_id.id + })] + }) + inventory.action_start() + inventory.action_validate() + + test_date_planned = Dt.now() - timedelta(days=1) + test_quantity = 3.0 + man_order_form = Form(self.env['mrp.production'].with_user(self.user_mrp_user)) + man_order_form.product_id = self.product_4 + man_order_form.bom_id = self.bom_1 + man_order_form.product_uom_id = self.product_4.uom_id + man_order_form.product_qty = test_quantity + man_order_form.date_planned_start = test_date_planned + man_order_form.location_src_id = self.location_1 + man_order_form.location_dest_id = self.warehouse_1.wh_output_stock_loc_id + man_order = man_order_form.save() + + self.assertEqual(man_order.state, 'draft', "Production order should be in draft state.") + man_order.action_confirm() + self.assertEqual(man_order.state, 'confirmed', "Production order should be in confirmed state.") + + # check production move + production_move = man_order.move_finished_ids + self.assertAlmostEqual(production_move.date, test_date_planned + timedelta(hours=1), delta=timedelta(seconds=10)) + self.assertEqual(production_move.product_id, self.product_4) + self.assertEqual(production_move.product_uom, man_order.product_uom_id) + self.assertEqual(production_move.product_qty, man_order.product_qty) + self.assertEqual(production_move.location_id, self.product_4.property_stock_production) + self.assertEqual(production_move.location_dest_id, man_order.location_dest_id) + + # check consumption moves + for move in man_order.move_raw_ids: + self.assertEqual(move.date, test_date_planned) + first_move = man_order.move_raw_ids.filtered(lambda move: move.product_id == self.product_2) + self.assertEqual(first_move.product_qty, test_quantity / self.bom_1.product_qty * self.product_4.uom_id.factor_inv * 2) + first_move = man_order.move_raw_ids.filtered(lambda move: move.product_id == self.product_1) + self.assertEqual(first_move.product_qty, test_quantity / self.bom_1.product_qty * self.product_4.uom_id.factor_inv * 4) + + # produce product + mo_form = Form(man_order) + mo_form.qty_producing = 2.0 + man_order = mo_form.save() + + action = man_order.button_mark_done() + self.assertEqual(man_order.state, 'progress', "Production order should be open a backorder wizard, then not done yet.") + + quantity_issues = man_order._get_consumption_issues() + action = man_order._action_generate_consumption_wizard(quantity_issues) + backorder = Form(self.env['mrp.production.backorder'].with_context(**action['context'])) + backorder.save().action_close_mo() + self.assertEqual(man_order.state, 'done', "Production order should be done.") + + # check that copy handles moves correctly + mo_copy = man_order.copy() + self.assertEqual(mo_copy.state, 'draft', "Copied production order should be draft.") + self.assertEqual(len(mo_copy.move_raw_ids), 2, "Incorrect number of component moves [i.e. no 0 qty moves should be copied].") + self.assertEqual(len(mo_copy.move_finished_ids), 1, "Incorrect number of moves for products to produce [i.e. cancelled moves should not be copied") + self.assertEqual(mo_copy.move_finished_ids.product_uom_qty, 2, "Incorrect qty of products to produce") + + def test_production_avialability(self): + """ Checks the availability of a production order through mutliple calls to `action_assign`. + """ + self.bom_3.bom_line_ids.filtered(lambda x: x.product_id == self.product_5).unlink() + self.bom_3.bom_line_ids.filtered(lambda x: x.product_id == self.product_4).unlink() + self.bom_3.ready_to_produce = 'all_available' + + production_form = Form(self.env['mrp.production']) + production_form.product_id = self.product_6 + production_form.bom_id = self.bom_3 + production_form.product_qty = 5.0 + production_form.product_uom_id = self.product_6.uom_id + production_2 = production_form.save() + + production_2.action_confirm() + production_2.action_assign() + + # check sub product availability state is waiting + self.assertEqual(production_2.reservation_state, 'confirmed', 'Production order should be availability for waiting state') + + # Update Inventory + self.env['stock.quant'].with_context(inventory_mode=True).create({ + 'product_id': self.product_2.id, + 'inventory_quantity': 2.0, + 'location_id': self.stock_location_14.id + }) + + production_2.action_assign() + # check sub product availability state is partially available + self.assertEqual(production_2.reservation_state, 'confirmed', 'Production order should be availability for partially available state') + + # Update Inventory + self.env['stock.quant'].with_context(inventory_mode=True).create({ + 'product_id': self.product_2.id, + 'inventory_quantity': 5.0, + 'location_id': self.stock_location_14.id + }) + + production_2.action_assign() + # check sub product availability state is assigned + self.assertEqual(production_2.reservation_state, 'assigned', 'Production order should be availability for assigned state') + + def test_split_move_line(self): + """ Consume more component quantity than the initial demand. + It should create extra move and share the quantity between the two stock + moves """ + mo, bom, p_final, p1, p2 = self.generate_mo(qty_base_1=10, qty_final=1, qty_base_2=1) + mo.action_assign() + # check is_quantity_done_editable + mo_form = Form(mo) + mo_form.qty_producing = 1 + mo = mo_form.save() + details_operation_form = Form(mo.move_raw_ids[0], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.edit(0) as ml: + ml.qty_done = 2 + details_operation_form.save() + details_operation_form = Form(mo.move_raw_ids[1], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.edit(0) as ml: + ml.qty_done = 11 + details_operation_form.save() + + self.assertEqual(len(mo.move_raw_ids), 2) + self.assertEqual(len(mo.move_raw_ids.mapped('move_line_ids')), 2) + self.assertEqual(mo.move_raw_ids[0].move_line_ids.mapped('qty_done'), [2]) + self.assertEqual(mo.move_raw_ids[1].move_line_ids.mapped('qty_done'), [11]) + self.assertEqual(mo.move_raw_ids[0].quantity_done, 2) + self.assertEqual(mo.move_raw_ids[1].quantity_done, 11) + mo.button_mark_done() + self.assertEqual(len(mo.move_raw_ids), 4) + self.assertEqual(len(mo.move_raw_ids.mapped('move_line_ids')), 4) + self.assertEqual(mo.move_raw_ids.mapped('quantity_done'), [1, 10, 1, 1]) + self.assertEqual(mo.move_raw_ids.mapped('move_line_ids.qty_done'), [1, 10, 1, 1]) + + def test_update_quantity_1(self): + """ Build 5 final products with different consumed lots, + then edit the finished quantity and update the Manufacturing + order quantity. Then check if the produced quantity do not + change and it is possible to close the MO. + """ + self.stock_location = self.env.ref('stock.stock_location_stock') + mo, bom, p_final, p1, p2 = self.generate_mo(tracking_base_1='lot') + self.assertEqual(len(mo), 1, 'MO should have been created') + + lot_1 = self.env['stock.production.lot'].create({ + 'name': 'lot1', + 'product_id': p1.id, + 'company_id': self.env.company.id, + }) + lot_2 = self.env['stock.production.lot'].create({ + 'name': 'lot2', + 'product_id': p1.id, + 'company_id': self.env.company.id, + }) + + self.env['stock.quant']._update_available_quantity(p1, self.stock_location, 10, lot_id=lot_1) + self.env['stock.quant']._update_available_quantity(p1, self.stock_location, 10, lot_id=lot_2) + + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 5) + mo.action_assign() + + mo_form = Form(mo) + mo_form.qty_producing = 1 + mo = mo_form.save() + + details_operation_form = Form(mo.move_raw_ids[1], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.new() as ml: + ml.lot_id = lot_1 + ml.qty_done = 20 + details_operation_form.save() + update_quantity_wizard = self.env['change.production.qty'].create({ + 'mo_id': mo.id, + 'product_qty': 4, + }) + update_quantity_wizard.change_prod_qty() + + self.assertEqual(mo.move_raw_ids.filtered(lambda m: m.product_id == p1).quantity_done, 20, 'Update the produce quantity should not impact already produced quantity.') + self.assertEqual(mo.move_finished_ids.product_uom_qty, 4) + mo.button_mark_done() + + def test_update_quantity_2(self): + """ Build 5 final products with different consumed lots, + then edit the finished quantity and update the Manufacturing + order quantity. Then check if the produced quantity do not + change and it is possible to close the MO. + """ + self.stock_location = self.env.ref('stock.stock_location_stock') + mo, bom, p_final, p1, p2 = self.generate_mo(qty_final=3) + self.assertEqual(len(mo), 1, 'MO should have been created') + + self.env['stock.quant']._update_available_quantity(p1, self.stock_location, 20) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 5) + mo.action_assign() + + mo_form = Form(mo) + mo_form.qty_producing = 2 + mo = mo_form.save() + + mo._post_inventory() + + update_quantity_wizard = self.env['change.production.qty'].create({ + 'mo_id': mo.id, + 'product_qty': 5, + }) + update_quantity_wizard.change_prod_qty() + mo_form = Form(mo) + mo_form.qty_producing = 5 + mo = mo_form.save() + mo.button_mark_done() + + self.assertEqual(sum(mo.move_raw_ids.filtered(lambda m: m.product_id == p1).mapped('quantity_done')), 20) + self.assertEqual(sum(mo.move_finished_ids.mapped('quantity_done')), 5) + + def test_update_quantity_3(self): + bom = self.env['mrp.bom'].create({ + 'product_id': self.product_6.id, + 'product_tmpl_id': self.product_6.product_tmpl_id.id, + 'product_qty': 1, + 'product_uom_id': self.product_6.uom_id.id, + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': self.product_2.id, 'product_qty': 2.03}), + (0, 0, {'product_id': self.product_8.id, 'product_qty': 4.16}) + ], + 'operation_ids': [ + (0, 0, {'name': 'Gift Wrap Maching', 'workcenter_id': self.workcenter_1.id, 'time_cycle': 15, 'sequence': 1}), + ] + }) + production_form = Form(self.env['mrp.production']) + production_form.product_id = self.product_6 + production_form.bom_id = bom + production_form.product_qty = 1 + production_form.product_uom_id = self.product_6.uom_id + production = production_form.save() + self.assertEqual(production.workorder_ids.duration_expected, 90) + mo_form = Form(production) + mo_form.product_qty = 3 + production = mo_form.save() + self.assertEqual(production.workorder_ids.duration_expected, 165) + + def test_update_quantity_4(self): + bom = self.env['mrp.bom'].create({ + 'product_id': self.product_6.id, + 'product_tmpl_id': self.product_6.product_tmpl_id.id, + 'product_qty': 1, + 'product_uom_id': self.product_6.uom_id.id, + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': self.product_2.id, 'product_qty': 2.03}), + (0, 0, {'product_id': self.product_8.id, 'product_qty': 4.16}) + ], + }) + production_form = Form(self.env['mrp.production']) + production_form.product_id = self.product_6 + production_form.bom_id = bom + production_form.product_qty = 1 + production_form.product_uom_id = self.product_6.uom_id + production = production_form.save() + production_form = Form(production) + with production_form.workorder_ids.new() as wo: + wo.name = 'OP1' + wo.workcenter_id = self.workcenter_1 + wo.duration_expected = 40 + production = production_form.save() + self.assertEqual(production.workorder_ids.duration_expected, 40) + mo_form = Form(production) + mo_form.product_qty = 3 + production = mo_form.save() + self.assertEqual(production.workorder_ids.duration_expected, 90) + + def test_qty_producing(self): + """Qty producing should be the qty remain to produce, instead of 0""" + bom = self.env['mrp.bom'].create({ + 'product_id': self.product_6.id, + 'product_tmpl_id': self.product_6.product_tmpl_id.id, + 'product_qty': 1, + 'product_uom_id': self.product_6.uom_id.id, + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': self.product_2.id, 'product_qty': 2.00}), + ], + }) + production_form = Form(self.env['mrp.production']) + production_form.product_id = self.product_6 + production_form.bom_id = bom + production_form.product_qty = 5 + production_form.product_uom_id = self.product_6.uom_id + production = production_form.save() + production_form = Form(production) + with production_form.workorder_ids.new() as wo: + wo.name = 'OP1' + wo.workcenter_id = self.workcenter_1 + wo.duration_expected = 40 + production = production_form.save() + production.action_confirm() + production.button_plan() + production.workorder_ids[0].button_start() + self.assertEqual(production.workorder_ids.qty_producing, 5, "Wrong quantity is suggested to produce.") + + def test_update_quantity_5(self): + bom = self.env['mrp.bom'].create({ + 'product_id': self.product_6.id, + 'product_tmpl_id': self.product_6.product_tmpl_id.id, + 'product_qty': 1, + 'product_uom_id': self.product_6.uom_id.id, + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': self.product_2.id, 'product_qty': 3}), + ], + }) + production_form = Form(self.env['mrp.production']) + production_form.product_id = self.product_6 + production_form.bom_id = bom + production_form.product_qty = 1 + production_form.product_uom_id = self.product_6.uom_id + production = production_form.save() + production.action_confirm() + production.action_assign() + production_form = Form(production) + # change the quantity producing and the initial demand + # in the same transaction + production_form.qty_producing = 10 + with production_form.move_raw_ids.edit(0) as move: + move.product_uom_qty = 2 + production = production_form.save() + production.button_mark_done() + + def test_update_plan_date(self): + """Editing the scheduled date after planning the MO should unplan the MO, and adjust the date on the stock moves""" + planned_date = datetime(2023, 5, 15, 9, 0) + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = self.product_4 + mo_form.bom_id = self.bom_1 + mo_form.product_qty = 1 + mo_form.date_planned_start = planned_date + mo = mo_form.save() + 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) + self.assertEqual(mo.move_finished_ids[0].date, datetime(2024, 5, 15, 10, 0)) + + def test_rounding(self): + """ Checks we round up when bringing goods to produce and round half-up when producing. + This implementation allows to implement an efficiency notion (see rev 347f140fe63612ee05e). + """ + self.product_6.uom_id.rounding = 1.0 + bom_eff = self.env['mrp.bom'].create({ + 'product_id': self.product_6.id, + 'product_tmpl_id': self.product_6.product_tmpl_id.id, + 'product_qty': 1, + 'product_uom_id': self.product_6.uom_id.id, + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': self.product_2.id, 'product_qty': 2.03}), + (0, 0, {'product_id': self.product_8.id, 'product_qty': 4.16}) + ] + }) + production_form = Form(self.env['mrp.production']) + production_form.product_id = self.product_6 + production_form.bom_id = bom_eff + production_form.product_qty = 20 + production_form.product_uom_id = self.product_6.uom_id + production = production_form.save() + production.action_confirm() + #Check the production order has the right quantities + self.assertEqual(production.move_raw_ids[0].product_qty, 41, 'The quantity should be rounded up') + self.assertEqual(production.move_raw_ids[1].product_qty, 84, 'The quantity should be rounded up') + + # produce product + mo_form = Form(production) + mo_form.qty_producing = 8 + production = mo_form.save() + self.assertEqual(production.move_raw_ids[0].quantity_done, 16, 'Should use half-up rounding when producing') + self.assertEqual(production.move_raw_ids[1].quantity_done, 34, 'Should use half-up rounding when producing') + + def test_product_produce_1(self): + """ Checks the production wizard contains lines even for untracked products. """ + self.stock_location = self.env.ref('stock.stock_location_stock') + mo, bom, p_final, p1, p2 = self.generate_mo() + self.assertEqual(len(mo), 1, 'MO should have been created') + + self.env['stock.quant']._update_available_quantity(p1, self.stock_location, 100) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 5) + + mo.action_assign() + + # change the quantity done in one line + details_operation_form = Form(mo.move_raw_ids[0], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.edit(0) as ml: + ml.qty_done = 1 + details_operation_form.save() + + # change the quantity producing + mo_form = Form(mo) + mo_form.qty_producing = 3 + + # check than all quantities are update correctly + self.assertEqual(mo_form.move_raw_ids._records[0]['product_uom_qty'], 5, "Wrong quantity to consume") + self.assertEqual(mo_form.move_raw_ids._records[0]['quantity_done'], 3, "Wrong quantity done") + self.assertEqual(mo_form.move_raw_ids._records[1]['product_uom_qty'], 20, "Wrong quantity to consume") + self.assertEqual(mo_form.move_raw_ids._records[1]['quantity_done'], 12, "Wrong quantity done") + + def test_product_produce_2(self): + """ Checks that, for a BOM where one of the components is tracked by serial number and the + other is not tracked, when creating a manufacturing order for two finished products and + reserving, the produce wizards proposes the corrects lines when producing one at a time. + """ + self.stock_location = self.env.ref('stock.stock_location_stock') + mo, bom, p_final, p1, p2 = self.generate_mo(tracking_base_1='serial', qty_base_1=1, qty_final=2) + self.assertEqual(len(mo), 1, 'MO should have been created') + + lot_p1_1 = self.env['stock.production.lot'].create({ + 'name': 'lot1', + 'product_id': p1.id, + 'company_id': self.env.company.id, + }) + lot_p1_2 = self.env['stock.production.lot'].create({ + 'name': 'lot2', + 'product_id': p1.id, + 'company_id': self.env.company.id, + }) + + self.env['stock.quant']._update_available_quantity(p1, self.stock_location, 1, lot_id=lot_p1_1) + self.env['stock.quant']._update_available_quantity(p1, self.stock_location, 1, lot_id=lot_p1_2) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 5) + + mo.action_assign() + + self.assertEqual(len(mo.move_raw_ids.move_line_ids), 3, 'You should have 3 stock move lines. One for each serial to consume and for the untracked product.') + mo_form = Form(mo) + mo_form.qty_producing = 1 + mo = mo_form.save() + + # get the proposed lot + details_operation_form = Form(mo.move_raw_ids.filtered(lambda move: move.product_id == p1), view=self.env.ref('stock.view_stock_move_operations')) + self.assertEqual(len(details_operation_form.move_line_ids), 2) + with details_operation_form.move_line_ids.edit(0) as ml: + consumed_lots = ml.lot_id + ml.qty_done = 1 + details_operation_form.save() + + remaining_lot = (lot_p1_1 | lot_p1_2) - consumed_lots + remaining_lot.ensure_one() + action = mo.button_mark_done() + backorder = Form(self.env['mrp.production.backorder'].with_context(**action['context'])) + backorder.save().action_backorder() + + # Check MO backorder + mo_backorder = mo.procurement_group_id.mrp_production_ids[-1] + + mo_form = Form(mo_backorder) + mo_form.qty_producing = 1 + mo_backorder = mo_form.save() + details_operation_form = Form(mo_backorder.move_raw_ids.filtered(lambda move: move.product_id == p1), view=self.env.ref('stock.view_stock_move_operations')) + self.assertEqual(len(details_operation_form.move_line_ids), 1) + with details_operation_form.move_line_ids.edit(0) as ml: + self.assertEqual(ml.lot_id, remaining_lot) + + def test_product_produce_3(self): + """ Checks that, for a BOM where one of the components is tracked by lot and the other is + not tracked, when creating a manufacturing order for 1 finished product and reserving, the + reserved lines are displayed. Then, over-consume by creating new line. + """ + self.stock_location = self.env.ref('stock.stock_location_stock') + self.stock_shelf_1 = self.stock_location_components + + self.stock_shelf_2 = self.stock_location_14 + mo, _, p_final, p1, p2 = self.generate_mo(tracking_base_1='lot', qty_base_1=10, qty_final=1) + self.assertEqual(len(mo), 1, 'MO should have been created') + + first_lot_for_p1 = self.env['stock.production.lot'].create({ + 'name': 'lot1', + 'product_id': p1.id, + 'company_id': self.env.company.id, + }) + second_lot_for_p1 = self.env['stock.production.lot'].create({ + 'name': 'lot2', + 'product_id': p1.id, + 'company_id': self.env.company.id, + }) + + final_product_lot = self.env['stock.production.lot'].create({ + 'name': 'lot1', + 'product_id': p_final.id, + 'company_id': self.env.company.id, + }) + + self.env['stock.quant']._update_available_quantity(p1, self.stock_shelf_1, 3, lot_id=first_lot_for_p1) + self.env['stock.quant']._update_available_quantity(p1, self.stock_shelf_2, 3, lot_id=first_lot_for_p1) + self.env['stock.quant']._update_available_quantity(p1, self.stock_location, 8, lot_id=second_lot_for_p1) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 5) + + mo.action_assign() + mo_form = Form(mo) + mo_form.qty_producing = 1.0 + mo_form.lot_producing_id = final_product_lot + mo = mo_form.save() + # p2 + details_operation_form = Form(mo.move_raw_ids[0], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.edit(0) as line: + line.qty_done = line.product_uom_qty + with details_operation_form.move_line_ids.new() as line: + line.qty_done = 1 + details_operation_form.save() + + # p1 + details_operation_form = Form(mo.move_raw_ids[1], view=self.env.ref('stock.view_stock_move_operations')) + for i in range(len(details_operation_form.move_line_ids)): + # reservation in shelf1: 3 lot1, shelf2: 3 lot1, stock: 4 lot2 + with details_operation_form.move_line_ids.edit(i) as line: + line.qty_done = line.product_uom_qty + with details_operation_form.move_line_ids.new() as line: + line.qty_done = 2 + line.lot_id = first_lot_for_p1 + with details_operation_form.move_line_ids.new() as line: + line.qty_done = 1 + line.lot_id = second_lot_for_p1 + details_operation_form.save() + + move_1 = mo.move_raw_ids.filtered(lambda m: m.product_id == p1) + # qty_done/product_uom_qty lot + # 3/3 lot 1 shelf 1 + # 1/1 lot 1 shelf 2 + # 2/2 lot 1 shelf 2 + # 2/0 lot 1 other + # 5/4 lot 2 + ml_to_shelf_1 = move_1.move_line_ids.filtered(lambda ml: ml.lot_id == first_lot_for_p1 and ml.location_id == self.stock_shelf_1) + ml_to_shelf_2 = move_1.move_line_ids.filtered(lambda ml: ml.lot_id == first_lot_for_p1 and ml.location_id == self.stock_shelf_2) + + self.assertEqual(sum(ml_to_shelf_1.mapped('qty_done')), 3.0, '3 units should be took from shelf1 as reserved.') + self.assertEqual(sum(ml_to_shelf_2.mapped('qty_done')), 3.0, '3 units should be took from shelf2 as reserved.') + self.assertEqual(move_1.quantity_done, 13, 'You should have used the tem units.') + + mo.button_mark_done() + self.assertEqual(mo.state, 'done', "Production order should be in done state.") + + def test_product_produce_4(self): + """ Possibility to produce with a given raw material in multiple locations. """ + # FIXME sle: how is it possible to consume before producing in the interface? + self.stock_location = self.env.ref('stock.stock_location_stock') + self.stock_shelf_1 = self.stock_location_components + self.stock_shelf_2 = self.stock_location_14 + mo, _, p_final, p1, p2 = self.generate_mo(qty_final=1, qty_base_1=5) + + self.env['stock.quant']._update_available_quantity(p1, self.stock_shelf_1, 2) + self.env['stock.quant']._update_available_quantity(p1, self.stock_shelf_2, 3) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 1) + + mo.action_assign() + ml_p1 = mo.move_raw_ids.filtered(lambda x: x.product_id == p1).mapped('move_line_ids') + ml_p2 = mo.move_raw_ids.filtered(lambda x: x.product_id == p2).mapped('move_line_ids') + self.assertEqual(len(ml_p1), 2) + self.assertEqual(len(ml_p2), 1) + + # Add some quantity already done to force an extra move line to be created + ml_p1[0].qty_done = 1.0 + + # Produce baby! + mo_form = Form(mo) + mo_form.qty_producing = 1 + mo = mo_form.save() + + m_p1 = mo.move_raw_ids.filtered(lambda x: x.product_id == p1) + ml_p1 = m_p1.mapped('move_line_ids') + self.assertEqual(len(ml_p1), 2) + self.assertEqual(sorted(ml_p1.mapped('qty_done')), [2.0, 3.0], 'Quantity done should be 1.0, 2.0 or 3.0') + self.assertEqual(m_p1.quantity_done, 5.0, 'Total qty done should be 6.0') + self.assertEqual(sum(ml_p1.mapped('product_uom_qty')), 5.0, 'Total qty reserved should be 5.0') + + mo.button_mark_done() + self.assertEqual(mo.state, 'done', "Production order should be in done state.") + + def test_product_produce_6(self): + """ Plan 5 finished products, reserve and produce 3. Post the current production. + Simulate an unlock and edit and, on the opened moves, set the consumed quantity + to 3. Now, try to update the quantity to mo2 to 3. It should fail since there + are consumed quantities. Unlock and edit, remove the consumed quantities and + update the quantity to produce to 3.""" + self.stock_location = self.env.ref('stock.stock_location_stock') + mo, bom, p_final, p1, p2 = self.generate_mo() + self.assertEqual(len(mo), 1, 'MO should have been created') + + self.env['stock.quant']._update_available_quantity(p1, self.stock_location, 20) + + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 5) + mo.action_assign() + + mo_form = Form(mo) + mo_form.qty_producing = 3 + mo = mo_form.save() + + mo._post_inventory() + self.assertEqual(len(mo.move_raw_ids), 4) + + mo.move_raw_ids.filtered(lambda m: m.state != 'done')[0].quantity_done = 3 + + update_quantity_wizard = self.env['change.production.qty'].create({ + 'mo_id': mo.id, + 'product_qty': 3, + }) + + mo.move_raw_ids.filtered(lambda m: m.state != 'done')[0].quantity_done = 0 + update_quantity_wizard.change_prod_qty() + + self.assertEqual(len(mo.move_raw_ids), 2) + + mo.button_mark_done() + self.assertTrue(all(s == 'done' for s in mo.move_raw_ids.mapped('state'))) + self.assertEqual(sum(mo.move_raw_ids.mapped('move_line_ids.product_uom_qty')), 0) + + def test_consumption_strict_1(self): + """ Checks the constraints of a strict BOM without tracking when playing around + quantities to consume.""" + self.stock_location = self.env.ref('stock.stock_location_stock') + mo, bom, p_final, p1, p2 = self.generate_mo(consumption='strict', qty_final=1) + self.assertEqual(len(mo), 1, 'MO should have been created') + + self.env['stock.quant']._update_available_quantity(p1, self.stock_location, 100) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 5) + + mo.action_assign() + + mo_form = Form(mo) + + # try adding another line for a bom product to increase the quantity + mo_form.qty_producing = 1 + with mo_form.move_raw_ids.new() as line: + line.product_id = p1 + mo = mo_form.save() + details_operation_form = Form(mo.move_raw_ids[-1], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.new() as ml: + ml.qty_done = 1 + details_operation_form.save() + # Won't accept to be done, instead return a wizard + mo.button_mark_done() + self.assertEqual(mo.state, 'to_close') + consumption_issues = mo._get_consumption_issues() + action = mo._action_generate_consumption_wizard(consumption_issues) + warning = Form(self.env['mrp.consumption.warning'].with_context(**action['context'])) + warning = warning.save() + + self.assertEqual(len(warning.mrp_consumption_warning_line_ids), 1) + self.assertEqual(warning.mrp_consumption_warning_line_ids[0].product_consumed_qty_uom, 5) + self.assertEqual(warning.mrp_consumption_warning_line_ids[0].product_expected_qty_uom, 4) + # Force the warning (as a manager) + warning.action_confirm() + self.assertEqual(mo.state, 'done') + + def test_consumption_warning_1(self): + """ Checks the constraints of a strict BOM without tracking when playing around + quantities to consume.""" + self.stock_location = self.env.ref('stock.stock_location_stock') + mo, bom, p_final, p1, p2 = self.generate_mo(consumption='warning', qty_final=1) + self.assertEqual(len(mo), 1, 'MO should have been created') + + self.env['stock.quant']._update_available_quantity(p1, self.stock_location, 100) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 5) + + mo.action_assign() + + mo_form = Form(mo) + + # try adding another line for a bom product to increase the quantity + mo_form.qty_producing = 1 + with mo_form.move_raw_ids.new() as line: + line.product_id = p1 + mo = mo_form.save() + details_operation_form = Form(mo.move_raw_ids[-1], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.new() as ml: + ml.qty_done = 1 + details_operation_form.save() + + # Won't accept to be done, instead return a wizard + mo.button_mark_done() + self.assertEqual(mo.state, 'to_close') + + consumption_issues = mo._get_consumption_issues() + action = mo._action_generate_consumption_wizard(consumption_issues) + warning = Form(self.env['mrp.consumption.warning'].with_context(**action['context'])) + warning = warning.save() + + self.assertEqual(len(warning.mrp_consumption_warning_line_ids), 1) + self.assertEqual(warning.mrp_consumption_warning_line_ids[0].product_consumed_qty_uom, 5) + self.assertEqual(warning.mrp_consumption_warning_line_ids[0].product_expected_qty_uom, 4) + # Force the warning (as a manager or employee) + warning.action_confirm() + self.assertEqual(mo.state, 'done') + + def test_consumption_flexible_1(self): + """ Checks the constraints of a strict BOM without tracking when playing around + quantities to consume.""" + self.stock_location = self.env.ref('stock.stock_location_stock') + mo, bom, p_final, p1, p2 = self.generate_mo(consumption='flexible', qty_final=1) + self.assertEqual(len(mo), 1, 'MO should have been created') + + self.env['stock.quant']._update_available_quantity(p1, self.stock_location, 100) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 5) + + mo.action_assign() + + mo_form = Form(mo) + + # try adding another line for a bom product to increase the quantity + mo_form.qty_producing = 1 + with mo_form.move_raw_ids.new() as line: + line.product_id = p1 + mo = mo_form.save() + details_operation_form = Form(mo.move_raw_ids[-1], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.new() as ml: + ml.qty_done = 1 + details_operation_form.save() + + # Won't accept to be done, instead return a wizard + mo.button_mark_done() + self.assertEqual(mo.state, 'done') + + def test_consumption_flexible_2(self): + """ Checks the constraints of a strict BOM only apply to the product of the BoM. """ + self.stock_location = self.env.ref('stock.stock_location_stock') + mo, bom, p_final, p1, p2 = self.generate_mo(consumption='flexible', qty_final=1) + self.assertEqual(len(mo), 1, 'MO should have been created') + + self.env['stock.quant']._update_available_quantity(p1, self.stock_location, 100) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 5) + add_product = self.env['product.product'].create({ + 'name': 'additional', + 'type': 'product', + }) + mo.action_assign() + + mo_form = Form(mo) + + # try adding another line for a bom product to increase the quantity + mo_form.qty_producing = 1 + with mo_form.move_raw_ids.new() as line: + line.product_id = p1 + with mo_form.move_raw_ids.new() as line: + line.product_id = add_product + mo = mo_form.save() + details_operation_form = Form(mo.move_raw_ids[-1], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.new() as ml: + ml.qty_done = 1 + details_operation_form.save() + + # Won't accept to be done, instead return a wizard + mo.button_mark_done() + self.assertEqual(mo.state, 'done') + + def test_product_produce_9(self): + """ Checks the production wizard contains lines even for untracked products. """ + serial = self.env['product.product'].create({ + 'name': 'S1', + 'tracking': 'serial', + }) + self.stock_location = self.env.ref('stock.stock_location_stock') + mo, bom, p_final, p1, p2 = self.generate_mo() + self.assertEqual(len(mo), 1, 'MO should have been created') + + self.env['stock.quant']._update_available_quantity(p1, self.stock_location, 100) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 5) + + mo.action_assign() + mo_form = Form(mo) + + # change the quantity done in one line + with self.assertRaises(AssertionError): + with mo_form.move_raw_ids.new() as move: + move.product_id = serial + move.quantity_done = 2 + mo_form.save() + + def test_product_produce_10(self): + """ Produce byproduct with serial, lot and not tracked. + byproduct1 serial 1.0 + byproduct2 lot 2.0 + byproduct3 none 1.0 dozen + Check qty producing update and moves finished values. + """ + dozen = self.env.ref('uom.product_uom_dozen') + self.byproduct1 = self.env['product.product'].create({ + 'name': 'Byproduct 1', + 'type': 'product', + 'tracking': 'serial' + }) + self.serial_1 = self.env['stock.production.lot'].create({ + 'product_id': self.byproduct1.id, + 'name': 'serial 1', + 'company_id': self.env.company.id, + }) + self.serial_2 = self.env['stock.production.lot'].create({ + 'product_id': self.byproduct1.id, + 'name': 'serial 2', + 'company_id': self.env.company.id, + }) + + self.byproduct2 = self.env['product.product'].create({ + 'name': 'Byproduct 2', + 'type': 'product', + 'tracking': 'lot', + }) + self.lot_1 = self.env['stock.production.lot'].create({ + 'product_id': self.byproduct2.id, + 'name': 'Lot 1', + 'company_id': self.env.company.id, + }) + self.lot_2 = self.env['stock.production.lot'].create({ + 'product_id': self.byproduct2.id, + 'name': 'Lot 2', + 'company_id': self.env.company.id, + }) + + self.byproduct3 = self.env['product.product'].create({ + 'name': 'Byproduct 3', + 'type': 'product', + 'tracking': 'none', + }) + + with Form(self.bom_1) as bom: + bom.product_qty = 1.0 + with bom.byproduct_ids.new() as bp: + bp.product_id = self.byproduct1 + bp.product_qty = 1.0 + with bom.byproduct_ids.new() as bp: + bp.product_id = self.byproduct2 + bp.product_qty = 2.0 + with bom.byproduct_ids.new() as bp: + bp.product_id = self.byproduct3 + bp.product_qty = 2.0 + bp.product_uom_id = dozen + + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = self.product_4 + mo_form.bom_id = self.bom_1 + mo_form.product_qty = 2 + mo = mo_form.save() + + mo.action_confirm() + move_byproduct_1 = mo.move_finished_ids.filtered(lambda l: l.product_id == self.byproduct1) + self.assertEqual(len(move_byproduct_1), 1) + self.assertEqual(move_byproduct_1.product_uom_qty, 2.0) + self.assertEqual(move_byproduct_1.quantity_done, 0) + self.assertEqual(len(move_byproduct_1.move_line_ids), 0) + + move_byproduct_2 = mo.move_finished_ids.filtered(lambda l: l.product_id == self.byproduct2) + self.assertEqual(len(move_byproduct_2), 1) + self.assertEqual(move_byproduct_2.product_uom_qty, 4.0) + self.assertEqual(move_byproduct_2.quantity_done, 0) + self.assertEqual(len(move_byproduct_2.move_line_ids), 0) + + move_byproduct_3 = mo.move_finished_ids.filtered(lambda l: l.product_id == self.byproduct3) + self.assertEqual(move_byproduct_3.product_uom_qty, 4.0) + self.assertEqual(move_byproduct_3.quantity_done, 0) + self.assertEqual(move_byproduct_3.product_uom, dozen) + self.assertEqual(len(move_byproduct_3.move_line_ids), 0) + + mo_form = Form(mo) + mo_form.qty_producing = 1.0 + mo = mo_form.save() + move_byproduct_1 = mo.move_finished_ids.filtered(lambda l: l.product_id == self.byproduct1) + self.assertEqual(len(move_byproduct_1), 1) + self.assertEqual(move_byproduct_1.product_uom_qty, 2.0) + self.assertEqual(move_byproduct_1.quantity_done, 0) + + move_byproduct_2 = mo.move_finished_ids.filtered(lambda l: l.product_id == self.byproduct2) + self.assertEqual(len(move_byproduct_2), 1) + self.assertEqual(move_byproduct_2.product_uom_qty, 4.0) + self.assertEqual(move_byproduct_2.quantity_done, 0) + + move_byproduct_3 = mo.move_finished_ids.filtered(lambda l: l.product_id == self.byproduct3) + self.assertEqual(move_byproduct_3.product_uom_qty, 4.0) + self.assertEqual(move_byproduct_3.quantity_done, 2.0) + self.assertEqual(move_byproduct_3.product_uom, dozen) + + details_operation_form = Form(move_byproduct_1, view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.new() as ml: + ml.lot_id = self.serial_1 + ml.qty_done = 1 + details_operation_form.save() + details_operation_form = Form(move_byproduct_2, view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.new() as ml: + ml.lot_id = self.lot_1 + ml.qty_done = 2 + details_operation_form.save() + action = mo.button_mark_done() + backorder = Form(self.env['mrp.production.backorder'].with_context(**action['context'])) + backorder.save().action_backorder() + mo2 = mo.procurement_group_id.mrp_production_ids[-1] + + mo_form = Form(mo2) + mo_form.qty_producing = 1 + mo2 = mo_form.save() + + move_byproduct_1 = mo2.move_finished_ids.filtered(lambda l: l.product_id == self.byproduct1) + self.assertEqual(len(move_byproduct_1), 1) + self.assertEqual(move_byproduct_1.product_uom_qty, 1.0) + self.assertEqual(move_byproduct_1.quantity_done, 0) + + move_byproduct_2 = mo2.move_finished_ids.filtered(lambda l: l.product_id == self.byproduct2) + self.assertEqual(len(move_byproduct_2), 1) + self.assertEqual(move_byproduct_2.product_uom_qty, 2.0) + self.assertEqual(move_byproduct_2.quantity_done, 0) + + move_byproduct_3 = mo2.move_finished_ids.filtered(lambda l: l.product_id == self.byproduct3) + self.assertEqual(move_byproduct_3.product_uom_qty, 2.0) + self.assertEqual(move_byproduct_3.quantity_done, 2.0) + self.assertEqual(move_byproduct_3.product_uom, dozen) + + details_operation_form = Form(move_byproduct_1, view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.new() as ml: + ml.lot_id = self.serial_2 + ml.qty_done = 1 + details_operation_form.save() + details_operation_form = Form(move_byproduct_2, view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.new() as ml: + ml.lot_id = self.lot_2 + ml.qty_done = 2 + details_operation_form.save() + details_operation_form = Form(move_byproduct_3, view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.edit(0) as ml: + ml.qty_done = 3 + details_operation_form.save() + + mo2.button_mark_done() + move_lines_byproduct_1 = (mo | mo2).move_finished_ids.filtered(lambda l: l.product_id == self.byproduct1).mapped('move_line_ids') + move_lines_byproduct_2 = (mo | mo2).move_finished_ids.filtered(lambda l: l.product_id == self.byproduct2).mapped('move_line_ids') + move_lines_byproduct_3 = (mo | mo2).move_finished_ids.filtered(lambda l: l.product_id == self.byproduct3).mapped('move_line_ids') + self.assertEqual(move_lines_byproduct_1.filtered(lambda ml: ml.lot_id == self.serial_1).qty_done, 1.0) + self.assertEqual(move_lines_byproduct_1.filtered(lambda ml: ml.lot_id == self.serial_2).qty_done, 1.0) + self.assertEqual(move_lines_byproduct_2.filtered(lambda ml: ml.lot_id == self.lot_1).qty_done, 2.0) + self.assertEqual(move_lines_byproduct_2.filtered(lambda ml: ml.lot_id == self.lot_2).qty_done, 2.0) + self.assertEqual(sum(move_lines_byproduct_3.mapped('qty_done')), 5.0) + self.assertEqual(move_lines_byproduct_3.mapped('product_uom_id'), dozen) + + def test_product_produce_11(self): + """ Checks that, for a BOM with two components, when creating a manufacturing order for one + finished products and without reserving, the produce wizards proposes the corrects lines + even if we change the quantity to produce multiple times. + """ + self.stock_location = self.env.ref('stock.stock_location_stock') + mo, bom, p_final, p1, p2 = self.generate_mo(qty_final=1) + self.assertEqual(len(mo), 1, 'MO should have been created') + + self.env['stock.quant']._update_available_quantity(p1, self.stock_location, 4) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 1) + + mo.bom_id.consumption = 'flexible' # Because we'll over-consume with a product not defined in the BOM + mo.action_assign() + + mo_form = Form(mo) + mo_form.qty_producing = 3 + self.assertEqual(sum([x['quantity_done'] for x in mo_form.move_raw_ids._records]), 15, 'Update the produce quantity should change the components quantity.') + mo = mo_form.save() + self.assertEqual(sum(mo.move_raw_ids.mapped('reserved_availability')), 5, 'Update the produce quantity should not change the components reserved quantity.') + mo_form = Form(mo) + mo_form.qty_producing = 4 + self.assertEqual(sum([x['quantity_done'] for x in mo_form.move_raw_ids._records]), 20, 'Update the produce quantity should change the components quantity.') + mo = mo_form.save() + self.assertEqual(sum(mo.move_raw_ids.mapped('reserved_availability')), 5, 'Update the produce quantity should not change the components reserved quantity.') + mo_form = Form(mo) + mo_form.qty_producing = 1 + self.assertEqual(sum([x['quantity_done'] for x in mo_form.move_raw_ids._records]), 5, 'Update the produce quantity should change the components quantity.') + mo = mo_form.save() + self.assertEqual(sum(mo.move_raw_ids.mapped('reserved_availability')), 5, 'Update the produce quantity should not change the components reserved quantity.') + # try adding another product that doesn't belong to the BoM + with mo_form.move_raw_ids.new() as move: + move.product_id = self.product_4 + mo = mo_form.save() + details_operation_form = Form(mo.move_raw_ids[-1], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.new() as ml: + ml.qty_done = 10 + details_operation_form.save() + # Check that this new product is not updated by qty_producing + mo_form = Form(mo) + mo_form.qty_producing = 2 + for move in mo_form.move_raw_ids._records: + if move['product_id'] == self.product_4.id: + self.assertEqual(move['quantity_done'], 10) + break + mo = mo_form.save() + mo.button_mark_done() + + def test_product_produce_duplicate_1(self): + """ produce a finished product tracked by serial number 2 times with the + same SN. Check that an error is raised the second time""" + mo1, bom, p_final, p1, p2 = self.generate_mo(tracking_final='serial', qty_final=1, qty_base_1=1,) + + mo_form = Form(mo1) + mo_form.qty_producing = 1 + mo1 = mo_form.save() + mo1.action_generate_serial() + sn = mo1.lot_producing_id + mo1.button_mark_done() + + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = p_final + mo_form.bom_id = bom + mo_form.product_qty = 1 + mo2 = mo_form.save() + mo2.action_confirm() + + mo_form = Form(mo2) + with self.assertLogs(level="WARNING"): + mo_form.lot_producing_id = sn + mo2 = mo_form.save() + with self.assertRaises(UserError): + mo2.button_mark_done() + + def test_product_produce_duplicate_2(self): + """ produce a finished product with component tracked by serial number 2 + times with the same SN. Check that an error is raised the second time""" + mo1, bom, p_final, p1, p2 = self.generate_mo(tracking_base_2='serial', qty_final=1, qty_base_1=1,) + sn = self.env['stock.production.lot'].create({ + 'name': 'sn used twice', + 'product_id': p2.id, + 'company_id': self.env.company.id, + }) + mo_form = Form(mo1) + mo_form.qty_producing = 1 + mo1 = mo_form.save() + details_operation_form = Form(mo1.move_raw_ids[0], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.new() as ml: + ml.lot_id = sn + details_operation_form.save() + mo1.button_mark_done() + + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = p_final + mo_form.bom_id = bom + mo_form.product_qty = 1 + mo2 = mo_form.save() + mo2.action_confirm() + + mo_form = Form(mo2) + mo_form.qty_producing = 1 + mo2 = mo_form.save() + details_operation_form = Form(mo2.move_raw_ids[0], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.new() as ml: + ml.lot_id = sn + details_operation_form.save() + with self.assertRaises(UserError): + mo2.button_mark_done() + + def test_product_produce_duplicate_3(self): + """ produce a finished product with by-product tracked by serial number 2 + times with the same SN. Check that an error is raised the second time""" + finished_product = self.env['product.product'].create({'name': 'finished product'}) + byproduct = self.env['product.product'].create({'name': 'byproduct', 'tracking': 'serial'}) + component = self.env['product.product'].create({'name': 'component'}) + bom = self.env['mrp.bom'].create({ + 'product_id': finished_product.id, + 'product_tmpl_id': finished_product.product_tmpl_id.id, + 'product_uom_id': finished_product.uom_id.id, + 'product_qty': 1.0, + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': component.id, 'product_qty': 1}), + ], + 'byproduct_ids': [ + (0, 0, {'product_id': byproduct.id, 'product_qty': 1, 'product_uom_id': byproduct.uom_id.id}) + ]}) + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = finished_product + mo_form.bom_id = bom + mo_form.product_qty = 1 + mo = mo_form.save() + mo.action_confirm() + + sn = self.env['stock.production.lot'].create({ + 'name': 'sn used twice', + 'product_id': byproduct.id, + 'company_id': self.env.company.id, + }) + + mo_form = Form(mo) + mo_form.qty_producing = 1 + mo = mo_form.save() + move_byproduct = mo.move_finished_ids.filtered(lambda m: m.product_id != mo.product_id) + details_operation_form = Form(move_byproduct, view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.new() as ml: + ml.lot_id = sn + details_operation_form.save() + mo.button_mark_done() + + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = finished_product + mo_form.bom_id = bom + mo_form.product_qty = 1 + mo2 = mo_form.save() + mo2.action_confirm() + + mo_form = Form(mo2) + mo_form.qty_producing = 1 + mo2 = mo_form.save() + move_byproduct = mo2.move_finished_ids.filtered(lambda m: m.product_id != mo.product_id) + details_operation_form = Form(move_byproduct, view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.new() as ml: + ml.lot_id = sn + details_operation_form.save() + with self.assertRaises(UserError): + mo2.button_mark_done() + + def test_product_produce_duplicate_4(self): + """ Consuming the same serial number two times should not give an error if + a repair order of the first production has been made before the second one""" + mo1, bom, p_final, p1, p2 = self.generate_mo(tracking_base_2='serial', qty_final=1, qty_base_1=1,) + sn = self.env['stock.production.lot'].create({ + 'name': 'sn used twice', + 'product_id': p2.id, + 'company_id': self.env.company.id, + }) + mo_form = Form(mo1) + mo_form.qty_producing = 1 + mo1 = mo_form.save() + details_operation_form = Form(mo1.move_raw_ids[0], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.new() as ml: + ml.lot_id = sn + details_operation_form.save() + mo1.button_mark_done() + + unbuild_form = Form(self.env['mrp.unbuild']) + unbuild_form.product_id = p_final + unbuild_form.bom_id = bom + unbuild_form.product_qty = 1 + unbuild_form.mo_id = mo1 + unbuild_order = unbuild_form.save() + unbuild_order.action_unbuild() + + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = p_final + mo_form.bom_id = bom + mo_form.product_qty = 1 + mo2 = mo_form.save() + mo2.action_confirm() + + mo_form = Form(mo2) + mo_form.qty_producing = 1 + mo2 = mo_form.save() + details_operation_form = Form(mo2.move_raw_ids[0], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.new() as ml: + ml.lot_id = sn + details_operation_form.save() + mo2.button_mark_done() + + def test_product_produce_12(self): + """ Checks that, the production is robust against deletion of finished move.""" + + self.stock_location = self.env.ref('stock.stock_location_stock') + mo, bom, p_final, p1, p2 = self.generate_mo(qty_final=1) + self.assertEqual(len(mo), 1, 'MO should have been created') + + mo_form = Form(mo) + mo_form.qty_producing = 1 + mo = mo_form.save() + # remove the finished move from the available to be updated + mo.move_finished_ids._action_done() + mo.button_mark_done() + + def test_product_produce_13(self): + """ Check that the production cannot be completed without any consumption.""" + product = self.env['product.product'].create({ + 'name': 'Product no BoM', + 'type': 'product', + }) + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = product + mo = mo_form.save() + move = self.env['stock.move'].create({ + 'name': 'mrp_move', + 'product_id': self.product_2.id, + 'product_uom': self.ref('uom.product_uom_unit'), + 'production_id': mo.id, + 'location_id': self.ref('stock.stock_location_stock'), + 'location_dest_id': self.ref('stock.stock_location_output'), + 'product_uom_qty': 0, + 'quantity_done': 0, + }) + mo.move_raw_ids |= move + mo.action_confirm() + + mo.qty_producing = 1 + # can't produce without any consumption (i.e. components w/ 0 consumed) + with self.assertRaises(UserError): + mo.button_mark_done() + + mo.move_raw_ids.quantity_done = 1 + mo.button_mark_done() + self.assertEqual(mo.state, 'done') + + def test_product_produce_uom(self): + """ Produce a finished product tracked by serial number. Set another + UoM on the bom. The produce wizard should keep the UoM of the product (unit) + and quantity = 1.""" + dozen = self.env.ref('uom.product_uom_dozen') + unit = self.env.ref('uom.product_uom_unit') + plastic_laminate = self.env['product.product'].create({ + 'name': 'Plastic Laminate', + 'type': 'product', + 'uom_id': unit.id, + 'uom_po_id': unit.id, + 'tracking': 'serial', + }) + ply_veneer = self.env['product.product'].create({ + 'name': 'Ply Veneer', + 'type': 'product', + 'uom_id': unit.id, + 'uom_po_id': unit.id, + }) + bom = self.env['mrp.bom'].create({ + 'product_tmpl_id': plastic_laminate.product_tmpl_id.id, + 'product_uom_id': unit.id, + 'sequence': 1, + 'bom_line_ids': [(0, 0, { + 'product_id': ply_veneer.id, + 'product_qty': 1, + 'product_uom_id': unit.id, + 'sequence': 1, + })] + }) + + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = plastic_laminate + mo_form.bom_id = bom + mo_form.product_uom_id = dozen + mo_form.product_qty = 1 + mo = mo_form.save() + + final_product_lot = self.env['stock.production.lot'].create({ + 'name': 'lot1', + 'product_id': plastic_laminate.id, + 'company_id': self.env.company.id, + }) + + mo.action_confirm() + mo.action_assign() + self.assertEqual(mo.move_raw_ids.product_qty, 12, '12 units should be reserved.') + + # produce product + mo_form = Form(mo) + mo_form.qty_producing = 1/12.0 + mo_form.lot_producing_id = final_product_lot + mo = mo_form.save() + + move_line_raw = mo.move_raw_ids.mapped('move_line_ids').filtered(lambda m: m.qty_done) + self.assertEqual(move_line_raw.qty_done, 1) + self.assertEqual(move_line_raw.product_uom_id, unit, 'Should be 1 unit since the tracking is serial.') + + mo._post_inventory() + move_line_finished = mo.move_finished_ids.mapped('move_line_ids').filtered(lambda m: m.qty_done) + self.assertEqual(move_line_finished.qty_done, 1) + self.assertEqual(move_line_finished.product_uom_id, unit, 'Should be 1 unit since the tracking is serial.') + + def test_product_type_service_1(self): + # Create finished product + finished_product = self.env['product.product'].create({ + 'name': 'Geyser', + 'type': 'product', + }) + + # Create service type product + product_raw = self.env['product.product'].create({ + 'name': 'raw Geyser', + 'type': 'service', + }) + + # Create bom for finish product + bom = self.env['mrp.bom'].create({ + 'product_id': finished_product.id, + 'product_tmpl_id': finished_product.product_tmpl_id.id, + 'product_uom_id': self.env.ref('uom.product_uom_unit').id, + 'product_qty': 1.0, + 'type': 'normal', + 'bom_line_ids': [(5, 0), (0, 0, {'product_id': product_raw.id})] + }) + + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = finished_product + mo_form.bom_id = bom + mo_form.product_uom_id = self.env.ref('uom.product_uom_unit') + mo_form.product_qty = 1 + mo = mo_form.save() + + # Check Mo is created or not + self.assertTrue(mo, "Mo is created") + + def test_immediate_validate_1(self): + """ In a production with a single available move raw, clicking on mark as done without filling any + quantities should open a wizard asking to process all the reservation (so, the whole move). + """ + mo, bom, p_final, p1, p2 = self.generate_mo(qty_final=1, qty_base_1=1, qty_base_2=1) + self.env['stock.quant']._update_available_quantity(p1, self.stock_location_components, 5.0) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location_components, 5.0) + mo.action_assign() + res_dict = mo.button_mark_done() + self.assertEqual(res_dict.get('res_model'), 'mrp.immediate.production') + wizard = Form(self.env[res_dict['res_model']].with_context(res_dict['context'])).save() + wizard.process() + self.assertEqual(mo.move_raw_ids.mapped('state'), ['done', 'done']) + self.assertEqual(mo.move_raw_ids.mapped('quantity_done'), [1, 1]) + self.assertEqual(mo.move_finished_ids.state, 'done') + self.assertEqual(mo.move_finished_ids.quantity_done, 1) + + def test_immediate_validate_2(self): + """ In a production with a single available move raw, clicking on mark as done after filling quantity + for a stock move only will trigger an error as qty_producing is left to 0.""" + mo, bom, p_final, p1, p2 = self.generate_mo(qty_final=1, qty_base_1=1, qty_base_2=1) + self.env['stock.quant']._update_available_quantity(p1, self.stock_location_components, 5.0) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location_components, 5.0) + mo.action_assign() + details_operation_form = Form(mo.move_raw_ids[0], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.new() as ml: + ml.qty_done = 1 + details_operation_form.save() + with self.assertRaises(UserError): + res_dict = mo.button_mark_done() + + def test_immediate_validate_3(self): + """ In a production with a serial number tracked product. Check that the immediate production only creates + one unit of finished product. Test with reservation.""" + mo, bom, p_final, p1, p2 = self.generate_mo(tracking_final='serial', qty_final=2, qty_base_1=1, qty_base_2=1) + self.env['stock.quant']._update_available_quantity(p1, self.stock_location_components, 5.0) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location_components, 5.0) + mo.action_assign() + action = mo.button_mark_done() + self.assertEqual(action.get('res_model'), 'mrp.immediate.production') + wizard = Form(self.env[action['res_model']].with_context(action['context'])).save() + action = wizard.process() + self.assertEqual(action.get('res_model'), 'mrp.production.backorder') + wizard = Form(self.env[action['res_model']].with_context(action['context'])).save() + action = wizard.action_backorder() + self.assertEqual(mo.qty_producing, 1) + self.assertEqual(mo.move_raw_ids.mapped('quantity_done'), [1, 1]) + self.assertEqual(len(mo.procurement_group_id.mrp_production_ids), 2) + mo_backorder = mo.procurement_group_id.mrp_production_ids[-1] + self.assertEqual(mo_backorder.product_qty, 1) + self.assertEqual(mo_backorder.move_raw_ids.mapped('product_uom_qty'), [1, 1]) + + def test_immediate_validate_4(self): + """ In a production with a serial number tracked product. Check that the immediate production only creates + one unit of finished product. Test without reservation.""" + mo, bom, p_final, p1, p2 = self.generate_mo(tracking_final='serial', qty_final=2, qty_base_1=1, qty_base_2=1) + self.env['stock.quant']._update_available_quantity(p1, self.stock_location_components, 5.0) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location_components, 5.0) + action = mo.button_mark_done() + self.assertEqual(action.get('res_model'), 'mrp.immediate.production') + wizard = Form(self.env[action['res_model']].with_context(action['context'])).save() + action = wizard.process() + self.assertEqual(action.get('res_model'), 'mrp.production.backorder') + wizard = Form(self.env[action['res_model']].with_context(action['context'])).save() + action = wizard.action_backorder() + self.assertEqual(mo.qty_producing, 1) + self.assertEqual(mo.move_raw_ids.mapped('quantity_done'), [1, 1]) + self.assertEqual(len(mo.procurement_group_id.mrp_production_ids), 2) + mo_backorder = mo.procurement_group_id.mrp_production_ids[-1] + self.assertEqual(mo_backorder.product_qty, 1) + self.assertEqual(mo_backorder.move_raw_ids.mapped('product_uom_qty'), [1, 1]) + + def test_immediate_validate_5(self): + """Validate three productions at once.""" + mo1, bom, p_final, p1, p2 = self.generate_mo(qty_final=1, qty_base_1=1, qty_base_2=1) + self.env['stock.quant']._update_available_quantity(p1, self.stock_location_components, 5.0) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location_components, 5.0) + mo1.action_assign() + mo2_form = Form(self.env['mrp.production']) + mo2_form.product_id = p_final + mo2_form.bom_id = bom + mo2_form.product_qty = 1 + mo2 = mo2_form.save() + mo2.action_confirm() + mo2.action_assign() + mo3_form = Form(self.env['mrp.production']) + mo3_form.product_id = p_final + mo3_form.bom_id = bom + mo3_form.product_qty = 1 + mo3 = mo3_form.save() + mo3.action_confirm() + mo3.action_assign() + mos = mo1 | mo2 | mo3 + res_dict = mos.button_mark_done() + self.assertEqual(res_dict.get('res_model'), 'mrp.immediate.production') + wizard = Form(self.env[res_dict['res_model']].with_context(res_dict['context'])).save() + wizard.process() + self.assertEqual(mos.move_raw_ids.mapped('state'), ['done'] * 6) + self.assertEqual(mos.move_raw_ids.mapped('quantity_done'), [1] * 6) + self.assertEqual(mos.move_finished_ids.mapped('state'), ['done'] * 3) + self.assertEqual(mos.move_finished_ids.mapped('quantity_done'), [1] * 3) + + def test_immediate_validate_6(self): + """In a production for a tracked product, clicking on mark as done without filling any quantities should + pop up the immediate transfer wizard. Processing should choose a new lot for the finished product. """ + mo, bom, p_final, p1, p2 = self.generate_mo(qty_final=1, qty_base_1=1, qty_base_2=1, tracking_final='lot') + self.env['stock.quant']._update_available_quantity(p1, self.stock_location_components, 5.0) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location_components, 5.0) + mo.action_assign() + res_dict = mo.button_mark_done() + self.assertEqual(res_dict.get('res_model'), 'mrp.immediate.production') + wizard = Form(self.env[res_dict['res_model']].with_context(res_dict['context'])).save() + wizard.process() + self.assertEqual(mo.move_raw_ids.mapped('state'), ['done'] * 2) + self.assertEqual(mo.move_raw_ids.mapped('quantity_done'), [1] * 2) + self.assertEqual(mo.move_finished_ids.state, 'done') + self.assertEqual(mo.move_finished_ids.quantity_done, 1) + self.assertTrue(mo.move_finished_ids.move_line_ids.lot_id != False) + + def test_immediate_validate_uom(self): + """In a production with a different uom than the finished product one, the + immediate production wizard should fill the correct quantities. """ + p_final = self.env['product.product'].create({ + 'name': 'final', + 'type': 'product', + }) + component = self.env['product.product'].create({ + 'name': 'component', + 'type': 'product', + }) + bom = self.env['mrp.bom'].create({ + 'product_id': p_final.id, + 'product_tmpl_id': p_final.product_tmpl_id.id, + 'product_uom_id': self.uom_unit.id, + 'product_qty': 1.0, + 'type': 'normal', + 'consumption': 'flexible', + 'bom_line_ids': [(0, 0, {'product_id': component.id, 'product_qty': 1})] + }) + self.env['stock.quant']._update_available_quantity(component, self.stock_location_components, 25.0) + mo_form = Form(self.env['mrp.production']) + mo_form.bom_id = bom + mo_form.product_uom_id = self.uom_dozen + mo_form.product_qty = 1 + mo = mo_form.save() + mo.action_confirm() + mo.action_assign() + res_dict = mo.button_mark_done() + self.assertEqual(res_dict.get('res_model'), 'mrp.immediate.production') + wizard = Form(self.env[res_dict['res_model']].with_context(res_dict['context'])).save() + wizard.process() + self.assertEqual(mo.move_raw_ids.state, 'done') + self.assertEqual(mo.move_raw_ids.quantity_done, 12) + self.assertEqual(mo.move_finished_ids.state, 'done') + self.assertEqual(mo.move_finished_ids.quantity_done, 1) + self.assertEqual(component.qty_available, 13) + + def test_immediate_validate_uom_2(self): + """The rounding precision of a component should be based on the UoM used in the MO for this component, + not on the produced product's UoM nor the default UoM of the component""" + uom_units = self.env['ir.model.data'].xmlid_to_object('uom.product_uom_unit') + uom_L = self.env['ir.model.data'].xmlid_to_object('uom.product_uom_litre') + uom_cL = self.env['uom.uom'].create({ + 'name': 'cL', + 'category_id': uom_L.category_id.id, + 'uom_type': 'smaller', + 'factor': 100, + 'rounding': 1, + }) + uom_units.rounding = 1 + uom_L.rounding = 0.01 + + product = self.env['product.product'].create({ + 'name': 'SuperProduct', + 'uom_id': uom_units.id, + }) + consumable_component = self.env['product.product'].create({ + 'name': 'Consumable Component', + 'type': 'consu', + 'uom_id': uom_cL.id, + 'uom_po_id': uom_cL.id, + }) + storable_component = self.env['product.product'].create({ + 'name': 'Storable Component', + 'type': 'product', + 'uom_id': uom_cL.id, + 'uom_po_id': uom_cL.id, + }) + self.env['stock.quant']._update_available_quantity(storable_component, self.env.ref('stock.stock_location_stock'), 100) + + for component in [consumable_component, storable_component]: + bom = self.env['mrp.bom'].create({ + 'product_tmpl_id': product.product_tmpl_id.id, + 'bom_line_ids': [(0, 0, { + 'product_id': component.id, + 'product_qty': 0.2, + 'product_uom_id': uom_L.id, + })], + }) + + mo_form = Form(self.env['mrp.production']) + mo_form.bom_id = bom + mo = mo_form.save() + mo.action_confirm() + action = mo.button_mark_done() + self.assertEqual(action.get('res_model'), 'mrp.immediate.production') + wizard = Form(self.env[action['res_model']].with_context(action['context'])).save() + action = wizard.process() + + self.assertEqual(mo.move_raw_ids.product_uom_qty, 0.2) + self.assertEqual(mo.move_raw_ids.quantity_done, 0.2) + + def test_copy(self): + """ Check that copying a done production, create all the stock moves""" + mo, bom, p_final, p1, p2 = self.generate_mo(qty_final=1, qty_base_1=1, qty_base_2=1) + mo.action_confirm() + mo_form = Form(mo) + mo_form.qty_producing = 1 + mo = mo_form.save() + mo.button_mark_done() + self.assertEqual(mo.state, 'done') + mo_copy = mo.copy() + self.assertTrue(mo_copy.move_raw_ids) + self.assertTrue(mo_copy.move_finished_ids) + mo_copy.action_confirm() + mo_form = Form(mo_copy) + mo_form.qty_producing = 1 + mo_copy = mo_form.save() + mo_copy.button_mark_done() + self.assertEqual(mo_copy.state, 'done') + + def test_product_produce_different_uom(self): + """ Check that for products tracked by lots, + with component product UOM different from UOM used in the BOM, + we do not create a new move line due to extra reserved quantity + caused by decimal rounding conversions. + """ + + # the overall decimal accuracy is set to 3 digits + precision = self.env.ref('product.decimal_product_uom') + precision.digits = 3 + + # define L and ml, L has rounding .001 but ml has rounding .01 + # when producing e.g. 187.5ml, it will be rounded to .188L + categ_test = self.env['uom.category'].create({'name': 'Volume Test'}) + + uom_L = self.env['uom.uom'].create({ + 'name': 'Test Liters', + 'category_id': categ_test.id, + 'uom_type': 'reference', + 'rounding': 0.001 + }) + + uom_ml = self.env['uom.uom'].create({ + 'name': 'Test ml', + 'category_id': categ_test.id, + 'uom_type': 'smaller', + 'rounding': 0.01, + 'factor_inv': 0.001, + }) + + # create a product component and the final product using the component + product_comp = self.env['product.product'].create({ + 'name': 'Product Component', + 'type': 'product', + 'tracking': 'lot', + 'categ_id': self.env.ref('product.product_category_all').id, + 'uom_id': uom_L.id, + 'uom_po_id': uom_L.id, + }) + + product_final = self.env['product.product'].create({ + 'name': 'Product Final', + 'type': 'product', + 'tracking': 'lot', + 'categ_id': self.env.ref('product.product_category_all').id, + 'uom_id': uom_L.id, + 'uom_po_id': uom_L.id, + }) + + # the products are tracked by lot, so we go through _generate_consumed_move_line + lot_final = self.env['stock.production.lot'].create({ + 'name': 'Lot Final', + 'product_id': product_final.id, + 'company_id': self.env.company.id, + }) + + lot_comp = self.env['stock.production.lot'].create({ + 'name': 'Lot Component', + 'product_id': product_comp.id, + 'company_id': self.env.company.id, + }) + + # update the quantity on hand for Component, in a lot + self.stock_location = self.env.ref('stock.stock_location_stock') + self.env['stock.quant']._update_available_quantity(product_comp, self.stock_location, 1, lot_id=lot_comp) + + # create a BOM for Final, using Component + test_bom = self.env['mrp.bom'].create({ + 'product_id': product_final.id, + 'product_tmpl_id': product_final.product_tmpl_id.id, + 'product_uom_id': uom_L.id, + 'product_qty': 1.0, + 'type': 'normal', + 'bom_line_ids': [(0, 0, { + 'product_id': product_comp.id, + 'product_qty': 375.00, + 'product_uom_id': uom_ml.id + })], + }) + + # create a MO for this BOM + mo_product_final_form = Form(self.env['mrp.production']) + mo_product_final_form.product_id = product_final + mo_product_final_form.product_uom_id = uom_L + mo_product_final_form.bom_id = test_bom + mo_product_final_form.product_qty = 0.5 + mo_product_final_form = mo_product_final_form.save() + + mo_product_final_form.action_confirm() + mo_product_final_form.action_assign() + self.assertEqual(mo_product_final_form.reservation_state, 'assigned') + + # produce + res_dict = mo_product_final_form.button_mark_done() + self.assertEqual(res_dict.get('res_model'), 'mrp.immediate.production') + wizard = Form(self.env[res_dict['res_model']].with_context(res_dict['context'])).save() + wizard.process() + + # check that in _generate_consumed_move_line, + # we do not create an extra move line because + # of a conversion 187.5ml = 0.188L + # thus creating an extra line with 'product_uom_qty': 0.5 + self.assertEqual(len(mo_product_final_form.move_raw_ids.move_line_ids), 1, 'One move line should exist for the MO.') + + def test_multi_button_plan(self): + """ Test batch methods (confirm/validate) of the MO with the same bom """ + self.bom_2.type = "normal" # avoid to get the operation of the kit bom + + mo_3 = Form(self.env['mrp.production']) + mo_3.bom_id = self.bom_3 + mo_3 = mo_3.save() + + self.assertEqual(len(mo_3.workorder_ids), 2) + + mo_3.button_plan() + self.assertEqual(mo_3.state, 'confirmed') + self.assertEqual(mo_3.workorder_ids[0].state, 'ready') + + mo_1 = Form(self.env['mrp.production']) + mo_1.bom_id = self.bom_3 + mo_1 = mo_1.save() + + mo_2 = Form(self.env['mrp.production']) + mo_2.bom_id = self.bom_3 + mo_2 = mo_2.save() + + self.assertEqual(mo_1.product_id, self.product_6) + self.assertEqual(mo_2.product_id, self.product_6) + self.assertEqual(len(self.bom_3.operation_ids), 2) + self.assertEqual(len(mo_1.workorder_ids), 2) + self.assertEqual(len(mo_2.workorder_ids), 2) + + (mo_1 | mo_2).button_plan() # Confirm and plan in the same "request" + self.assertEqual(mo_1.state, 'confirmed') + self.assertEqual(mo_2.state, 'confirmed') + self.assertEqual(mo_1.workorder_ids[0].state, 'ready') + self.assertEqual(mo_2.workorder_ids[0].state, 'ready') + + # produce + res_dict = (mo_1 | mo_2).button_mark_done() + self.assertEqual(res_dict.get('res_model'), 'mrp.immediate.production') + wizard = Form(self.env[res_dict['res_model']].with_context(res_dict['context'])).save() + wizard.process() + self.assertEqual(mo_1.state, 'done') + self.assertEqual(mo_2.state, 'done') + + def test_workcenter_timezone(self): + # Workcenter is based in Bangkok + # Possible working hours are Monday to Friday, from 8:00 to 12:00 and from 13:00 to 17:00 (UTC+7) + workcenter = self.workcenter_1 + workcenter.resource_calendar_id.tz = 'Asia/Bangkok' + + bom = self.env['mrp.bom'].create({ + 'product_tmpl_id': self.product_1.product_tmpl_id.id, + 'bom_line_ids': [(0, 0, { + 'product_id': self.product_2.id, + })], + 'operation_ids': [(0, 0, { + 'name': 'SuperOperation01', + 'workcenter_id': workcenter.id, + }), (0, 0, { + 'name': 'SuperOperation01', + 'workcenter_id': workcenter.id, + })], + }) + + # Next Monday at 6:00 am UTC + date_planned = (Dt.now() + timedelta(days=7 - Dt.now().weekday())).replace(hour=6, minute=0, second=0) + mo_form = Form(self.env['mrp.production']) + mo_form.bom_id = bom + mo_form.date_planned_start = date_planned + mo = mo_form.save() + + mo.workorder_ids[0].duration_expected = 240 + mo.workorder_ids[1].duration_expected = 60 + + mo.action_confirm() + mo.button_plan() + + # Asia/Bangkok is UTC+7 and the start date is on Monday at 06:00 UTC (i.e., 13:00 UTC+7). + # So, in Bangkok, the first workorder uses the entire Monday afternoon slot 13:00 - 17:00 UTC+7 (i.e., 06:00 - 10:00 UTC) + # The second job uses the beginning of the Tuesday morning slot: 08:00 - 09:00 UTC+7 (i.e., 01:00 - 02:00 UTC) + self.assertEqual(mo.workorder_ids[0].date_planned_start, date_planned) + self.assertEqual(mo.workorder_ids[0].date_planned_finished, date_planned + timedelta(hours=4)) + tuesday = date_planned + timedelta(days=1) + self.assertEqual(mo.workorder_ids[1].date_planned_start, tuesday.replace(hour=1)) + self.assertEqual(mo.workorder_ids[1].date_planned_finished, tuesday.replace(hour=2)) + + def test_products_with_variants(self): + """Check for product with different variants with same bom""" + product = self.env['product.template'].create({ + "attribute_line_ids": [ + [0, 0, {"attribute_id": 2, "value_ids": [[6, 0, [3, 4]]]}] + ], + "name": "Product with variants", + }) + + variant_1 = product.product_variant_ids[0] + variant_2 = product.product_variant_ids[1] + + component = self.env['product.template'].create({ + "name": "Component", + }) + + self.env['mrp.bom'].create({ + 'product_id': False, + 'product_tmpl_id': product.id, + 'bom_line_ids': [ + (0, 0, {'product_id': component.product_variant_id.id, 'product_qty': 1}) + ] + }) + + # First behavior to check, is changing the product (same product but another variant) after saving the MO a first time. + mo_form_1 = Form(self.env['mrp.production']) + mo_form_1.product_id = variant_1 + mo_1 = mo_form_1.save() + mo_form_1 = Form(self.env['mrp.production'].browse(mo_1.id)) + mo_form_1.product_id = variant_2 + mo_1 = mo_form_1.save() + mo_1.action_confirm() + mo_1.action_assign() + mo_form_1 = Form(self.env['mrp.production'].browse(mo_1.id)) + mo_form_1.qty_producing = 1 + mo_1 = mo_form_1.save() + mo_1.button_mark_done() + + move_lines_1 = self.env['stock.move.line'].search([("reference", "=", mo_1.name)]) + move_finished_ids_1 = self.env['stock.move'].search([("production_id", "=", mo_1.id)]) + self.assertEqual(len(move_lines_1), 2, "There should only be 2 move lines: the component line and produced product line") + self.assertEqual(len(move_finished_ids_1), 1, "There should only be 1 produced product for this MO") + self.assertEqual(move_finished_ids_1.product_id, variant_2, "Incorrect variant produced") + + # Second behavior is changing the product before saving the MO + mo_form_2 = Form(self.env['mrp.production']) + mo_form_2.product_id = variant_1 + mo_form_2.product_id = variant_2 + mo_2 = mo_form_2.save() + mo_2.action_confirm() + mo_2.action_assign() + mo_form_2 = Form(self.env['mrp.production'].browse(mo_2.id)) + mo_form_2.qty_producing = 1 + mo_2 = mo_form_2.save() + mo_2.button_mark_done() + + move_lines_2 = self.env['stock.move.line'].search([("reference", "=", mo_2.name)]) + move_finished_ids_2 = self.env['stock.move'].search([("production_id", "=", mo_2.id)]) + self.assertEqual(len(move_lines_2), 2, "There should only be 2 move lines: the component line and produced product line") + self.assertEqual(len(move_finished_ids_2), 1, "There should only be 1 produced product for this MO") + self.assertEqual(move_finished_ids_2.product_id, variant_2, "Incorrect variant produced") + + # Third behavior is changing the product before saving the MO, then another time after + mo_form_3 = Form(self.env['mrp.production']) + mo_form_3.product_id = variant_1 + mo_form_3.product_id = variant_2 + mo_3 = mo_form_3.save() + mo_form_3 = Form(self.env['mrp.production'].browse(mo_3.id)) + mo_form_3.product_id = variant_1 + mo_3 = mo_form_3.save() + mo_3.action_confirm() + mo_3.action_assign() + mo_form_3 = Form(self.env['mrp.production'].browse(mo_3.id)) + mo_form_3.qty_producing = 1 + mo_3 = mo_form_3.save() + mo_3.button_mark_done() + + move_lines_3 = self.env['stock.move.line'].search([("reference", "=", mo_3.name)]) + move_finished_ids_3 = self.env['stock.move'].search([("production_id", "=", mo_3.id)]) + self.assertEqual(len(move_lines_3), 2, "There should only be 2 move lines: the component line and produced product line") + self.assertEqual(len(move_finished_ids_3), 1, "There should only be 1 produced product for this MO") + self.assertEqual(move_finished_ids_3.product_id, variant_1, "Incorrect variant produced") + + def test_manufacturing_order_with_work_orders(self): + """Test the behavior of a manufacturing order when opening the workorder related to it, + as well as the behavior when a backorder is created + """ + # create a few work centers + work_center_1 = self.env['mrp.workcenter'].create({"name": "WC1"}) + work_center_2 = self.env['mrp.workcenter'].create({"name": "WC2"}) + work_center_3 = self.env['mrp.workcenter'].create({"name": "WC3"}) + + # create a product, a bom related to it with 3 components and 3 operations + product = self.env['product.template'].create({"name": "Product"}) + component_1 = self.env['product.template'].create({"name": "Component 1", "type": "product"}) + component_2 = self.env['product.template'].create({"name": "Component 2", "type": "product"}) + component_3 = self.env['product.template'].create({"name": "Component 3", "type": "product"}) + + self.env['stock.quant'].create({ + "product_id": component_1.product_variant_id.id, + "location_id": 8, + "inventory_quantity": 100 + }) + self.env['stock.quant'].create({ + "product_id": component_2.product_variant_id.id, + "location_id": 8, + "inventory_quantity": 100 + }) + self.env['stock.quant'].create({ + "product_id": component_3.product_variant_id.id, + "location_id": 8, + "inventory_quantity": 100 + }) + + self.env['mrp.bom'].create({ + "product_tmpl_id": product.id, + "product_id": False, + "product_qty": 1, + "bom_line_ids": [ + [0, 0, {"product_id": component_1.product_variant_id.id, "product_qty": 1}], + [0, 0, {"product_id": component_2.product_variant_id.id, "product_qty": 1}], + [0, 0, {"product_id": component_3.product_variant_id.id, "product_qty": 1}] + ], + "operation_ids": [ + [0, 0, {"name": "Operation 1", "workcenter_id": work_center_1.id}], + [0, 0, {"name": "Operation 2", "workcenter_id": work_center_2.id}], + [0, 0, {"name": "Operation 3", "workcenter_id": work_center_3.id}] + ] + }) + + # create a manufacturing order with 10 product to produce + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = product.product_variant_id + mo_form.product_qty = 10 + mo = mo_form.save() + + self.assertEqual(mo.state, 'draft') + mo.action_confirm() + + wo_1 = mo.workorder_ids[0] + wo_2 = mo.workorder_ids[1] + wo_3 = mo.workorder_ids[2] + self.assertEqual(mo.state, 'confirmed') + + wo_1.button_start() + self.assertEqual(mo.state, 'progress') + wo_1.button_finish() + + wo_2.button_start() + wo_2.qty_producing = 8 + wo_2.button_finish() + + wo_3.button_start() + wo_3.qty_producing = 8 + wo_3.button_finish() + + self.assertEqual(mo.state, 'to_close') + mo.button_mark_done() + + bo = self.env['mrp.production.backorder'].create({ + "mrp_production_backorder_line_ids": [ + [0, 0, {"mrp_production_id": mo.id, "to_backorder": True}] + ] + }) + bo.action_backorder() + + self.assertEqual(mo.state, 'done') + + mo_2 = self.env['mrp.production'].browse(mo.id + 1) + self.assertEqual(mo_2.state, 'progress') + wo_4, wo_5, wo_6 = mo_2.workorder_ids + self.assertEqual(wo_4.state, 'cancel') + + wo_5.button_start() + self.assertEqual(mo_2.state, 'progress') + wo_5.button_finish() + + wo_6.button_start() + wo_6.button_finish() + self.assertEqual(mo_2.state, 'to_close') + mo_2.button_mark_done() + self.assertEqual(mo_2.state, 'done') + + def test_move_finished_onchanges(self): + """ Test that move_finished_ids (i.e. produced products) are still correct even after + multiple onchanges have changed the the moves + """ + + product1 = self.env['product.product'].create({ + 'name': 'Oatmeal Cookie', + }) + product2 = self.env['product.product'].create({ + 'name': 'Chocolate Chip Cookie', + }) + + # ===== product_id onchange checks ===== # + # check product_id onchange without saving + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = product1 + mo_form.product_id = product2 + mo = mo_form.save() + self.assertEqual(len(mo.move_finished_ids), 1, 'Wrong number of finished product moves created') + self.assertEqual(mo.move_finished_ids.product_id, product2, 'Wrong product to produce in finished product move') + # check product_id onchange after saving + mo_form = Form(self.env['mrp.production'].browse(mo.id)) + mo_form.product_id = product1 + mo = mo_form.save() + self.assertEqual(len(mo.move_finished_ids), 1, 'Wrong number of finish product moves created') + self.assertEqual(mo.move_finished_ids.product_id, product1, 'Wrong product to produce in finished product move') + # check product_id onchange when mo._origin.product_id is unchanged + mo_form = Form(self.env['mrp.production'].browse(mo.id)) + mo_form.product_id = product2 + mo_form.product_id = product1 + mo = mo_form.save() + self.assertEqual(len(mo.move_finished_ids), 1, 'Wrong number of finish product moves created') + self.assertEqual(mo.move_finished_ids.product_id, product1, 'Wrong product to produce in finished product move') + + # ===== product_qty onchange checks ===== # + # check product_qty onchange without saving + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = product1 + mo_form.product_qty = 5 + mo_form.product_qty = 10 + mo2 = mo_form.save() + self.assertEqual(len(mo2.move_finished_ids), 1, 'Wrong number of finished product moves created') + self.assertEqual(mo2.move_finished_ids.product_qty, 10, 'Wrong qty to produce for the finished product move') + + # check product_qty onchange after saving + mo_form = Form(self.env['mrp.production'].browse(mo2.id)) + mo_form.product_qty = 5 + mo2 = mo_form.save() + self.assertEqual(len(mo2.move_finished_ids), 1, 'Wrong number of finish product moves created') + self.assertEqual(mo2.move_finished_ids.product_qty, 5, 'Wrong qty to produce for the finished product move') + + # check product_qty onchange when mo._origin.product_id is unchanged + mo_form = Form(self.env['mrp.production'].browse(mo2.id)) + mo_form.product_qty = 10 + mo_form.product_qty = 5 + mo2 = mo_form.save() + self.assertEqual(len(mo2.move_finished_ids), 1, 'Wrong number of finish product moves created') + self.assertEqual(mo2.move_finished_ids.product_qty, 5, 'Wrong qty to produce for the finished product move') + + # ===== product_uom_id onchange checks ===== # + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = product1 + mo_form.product_qty = 1 + mo_form.product_uom_id = self.env['uom.uom'].browse(self.ref('uom.product_uom_dozen')) + mo3 = mo_form.save() + self.assertEqual(len(mo3.move_finished_ids), 1, 'Wrong number of finish product moves created') + self.assertEqual(mo3.move_finished_ids.product_qty, 12, 'Wrong qty to produce for the finished product move') + + # ===== bom_id onchange checks ===== # + component = self.env['product.product'].create({ + "name": "Sugar", + }) + + bom1 = self.env['mrp.bom'].create({ + 'product_id': False, + 'product_tmpl_id': product1.product_tmpl_id.id, + 'bom_line_ids': [ + (0, 0, {'product_id': component.id, 'product_qty': 1}) + ] + }) + + bom2 = self.env['mrp.bom'].create({ + 'product_id': False, + 'product_tmpl_id': product1.product_tmpl_id.id, + 'bom_line_ids': [ + (0, 0, {'product_id': component.id, 'product_qty': 10}) + ] + }) + # check bom_id onchange before product change + mo_form = Form(self.env['mrp.production']) + mo_form.bom_id = bom1 + mo_form.bom_id = bom2 + mo_form.product_id = product2 + mo4 = mo_form.save() + self.assertFalse(mo4.bom_id, 'BoM should have been removed') + self.assertEqual(len(mo4.move_finished_ids), 1, 'Wrong number of finished product moves created') + self.assertEqual(mo4.move_finished_ids.product_id, product2, 'Wrong product to produce in finished product move') + # check bom_id onchange after product change + mo_form = Form(self.env['mrp.production'].browse(mo4.id)) + mo_form.product_id = product1 + mo_form.bom_id = bom1 + mo_form.bom_id = bom2 + mo4 = mo_form.save() + self.assertEqual(len(mo4.move_finished_ids), 1, 'Wrong number of finish product moves created') + self.assertEqual(mo4.move_finished_ids.product_id, product1, 'Wrong product to produce in finished product move') + # check product_id onchange when mo._origin.product_id is unchanged + mo_form = Form(self.env['mrp.production'].browse(mo4.id)) + mo_form.bom_id = bom2 + mo_form.bom_id = bom1 + mo4 = mo_form.save() + self.assertEqual(len(mo4.move_finished_ids), 1, 'Wrong number of finish product moves created') + self.assertEqual(mo4.move_finished_ids.product_id, product1, 'Wrong product to produce in finished product move') diff --git a/addons/mrp/tests/test_procurement.py b/addons/mrp/tests/test_procurement.py new file mode 100644 index 00000000..b7da5255 --- /dev/null +++ b/addons/mrp/tests/test_procurement.py @@ -0,0 +1,598 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from datetime import datetime, timedelta + +from odoo import fields +from odoo.tests import Form +from odoo.addons.mrp.tests.common import TestMrpCommon +from odoo.exceptions import UserError +from odoo.tools import mute_logger + + +class TestProcurement(TestMrpCommon): + + def test_procurement(self): + """This test case when create production order check procurement is create""" + # Update BOM + self.bom_3.bom_line_ids.filtered(lambda x: x.product_id == self.product_5).unlink() + self.bom_1.bom_line_ids.filtered(lambda x: x.product_id == self.product_1).unlink() + # Update route + self.warehouse = self.env.ref('stock.warehouse0') + self.warehouse.mto_pull_id.route_id.active = True + route_manufacture = self.warehouse.manufacture_pull_id.route_id.id + route_mto = self.warehouse.mto_pull_id.route_id.id + self.product_4.write({'route_ids': [(6, 0, [route_manufacture, route_mto])]}) + + # Create production order + # ------------------------- + # Product6 Unit 24 + # Product4 8 Dozen + # Product2 12 Unit + # ----------------------- + + production_form = Form(self.env['mrp.production']) + production_form.product_id = self.product_6 + production_form.bom_id = self.bom_3 + production_form.product_qty = 24 + production_form.product_uom_id = self.product_6.uom_id + production_product_6 = production_form.save() + production_product_6.action_confirm() + production_product_6.action_assign() + + # check production state is Confirmed + self.assertEqual(production_product_6.state, 'confirmed') + + # Check procurement for product 4 created or not. + # Check it created a purchase order + + move_raw_product4 = production_product_6.move_raw_ids.filtered(lambda x: x.product_id == self.product_4) + produce_product_4 = self.env['mrp.production'].search([('product_id', '=', self.product_4.id), + ('move_dest_ids', '=', move_raw_product4[0].id)]) + # produce product + self.assertEqual(produce_product_4.reservation_state, 'confirmed', "Consume material not available") + + # Create production order + # ------------------------- + # Product 4 96 Unit + # Product2 48 Unit + # --------------------- + # Update Inventory + self.env['stock.quant'].with_context(inventory_mode=True).create({ + 'product_id': self.product_2.id, + 'inventory_quantity': 48, + 'location_id': self.warehouse.lot_stock_id.id, + }) + produce_product_4.action_assign() + self.assertEqual(produce_product_4.product_qty, 8, "Wrong quantity of finish product.") + self.assertEqual(produce_product_4.product_uom_id, self.uom_dozen, "Wrong quantity of finish product.") + self.assertEqual(produce_product_4.reservation_state, 'assigned', "Consume material not available") + + # produce product4 + # --------------- + + mo_form = Form(produce_product_4) + mo_form.qty_producing = produce_product_4.product_qty + produce_product_4 = mo_form.save() + # Check procurement and Production state for product 4. + produce_product_4.button_mark_done() + self.assertEqual(produce_product_4.state, 'done', 'Production order should be in state done') + + # Produce product 6 + # ------------------ + + # Update Inventory + self.env['stock.quant'].with_context(inventory_mode=True).create({ + 'product_id': self.product_2.id, + 'inventory_quantity': 12, + 'location_id': self.warehouse.lot_stock_id.id, + }) + production_product_6.action_assign() + + # ------------------------------------ + + self.assertEqual(production_product_6.reservation_state, 'assigned', "Consume material not available") + mo_form = Form(production_product_6) + mo_form.qty_producing = production_product_6.product_qty + production_product_6 = mo_form.save() + # Check procurement and Production state for product 6. + production_product_6.button_mark_done() + self.assertEqual(production_product_6.state, 'done', 'Production order should be in state done') + self.assertEqual(self.product_6.qty_available, 24, 'Wrong quantity available of finished product.') + + def test_procurement_2(self): + """Check that a manufacturing order create the right procurements when the route are set on + a parent category of a product""" + # find a child category id + all_categ_id = self.env['product.category'].search([('parent_id', '=', None)], limit=1) + child_categ_id = self.env['product.category'].search([('parent_id', '=', all_categ_id.id)], limit=1) + + # set the product of `self.bom_1` to this child category + for bom_line_id in self.bom_1.bom_line_ids: + # check that no routes are defined on the product + self.assertEqual(len(bom_line_id.product_id.route_ids), 0) + # set the category of the product to a child category + bom_line_id.product_id.categ_id = child_categ_id + + # set the MTO route to the parent category (all) + self.warehouse = self.env.ref('stock.warehouse0') + mto_route = self.warehouse.mto_pull_id.route_id + mto_route.active = True + mto_route.product_categ_selectable = True + all_categ_id.write({'route_ids': [(6, 0, [mto_route.id])]}) + + # create MO, but check it raises error as components are in make to order and not everyone has + with self.assertRaises(UserError): + production_form = Form(self.env['mrp.production']) + production_form.product_id = self.product_4 + production_form.product_uom_id = self.product_4.uom_id + production_form.product_qty = 1 + production_product_4 = production_form.save() + production_product_4.action_confirm() + + def test_procurement_4(self): + warehouse = self.env['stock.warehouse'].search([], limit=1) + product_A = self.env['product.product'].create({ + 'name': 'productA', + 'type': 'product', + 'route_ids': [(4, self.ref('mrp.route_warehouse0_manufacture'))] + }) + product_B = self.env['product.product'].create({ + 'name': 'productB', + 'type': 'product', + 'route_ids': [(4, self.ref('mrp.route_warehouse0_manufacture'))] + }) + product_C = self.env['product.product'].create({ + 'name': 'productC', + 'type': 'product', + }) + product_route = self.env['stock.location.route'].create({ + 'name': 'Stock -> output route', + 'product_selectable': True, + 'rule_ids': [(0, 0, { + 'name': 'Stock -> output rule', + 'action': 'pull', + 'picking_type_id': self.ref('stock.picking_type_internal'), + 'location_src_id': self.ref('stock.stock_location_stock'), + 'location_id': self.ref('stock.stock_location_output'), + })], + }) + + # Set this route on `product.product_product_3` + product_C.write({ + 'route_ids': [(4, product_route.id)] + }) + + bom_A = self.env['mrp.bom'].create({ + 'product_id': product_A.id, + 'product_tmpl_id': product_A.product_tmpl_id.id, + 'product_uom_id': self.uom_unit.id, + 'product_qty': 1.0, + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': product_B.id, 'product_qty': 2.0}) + ]}) + + self.env['stock.warehouse.orderpoint'].create({ + 'name': 'A RR', + 'location_id': warehouse.lot_stock_id.id, + 'product_id': product_A.id, + 'product_min_qty': 10, + 'product_max_qty': 100, + }) + + bom_B = self.env['mrp.bom'].create({ + 'product_id': product_B.id, + 'product_tmpl_id': product_B.product_tmpl_id.id, + 'product_uom_id': self.uom_unit.id, + 'product_qty': 1.0, + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': product_C.id, 'product_qty': 1.0}) + ]}) + + self.env['stock.warehouse.orderpoint'].create({ + 'name': 'B RR', + 'location_id': warehouse.lot_stock_id.id, + 'product_id': product_B.id, + 'product_min_qty': 20, + 'product_max_qty': 200, + }) + + self.env['stock.warehouse.orderpoint'].create({ + 'name': 'C RR', + 'location_id': warehouse.lot_stock_id.id, + 'product_id': product_C.id, + 'product_min_qty': 20, + 'product_max_qty': 200, + }) + + with mute_logger('odoo.addons.stock.models.procurement'): + self.env['procurement.group'].run_scheduler() + + production_A = self.env['mrp.production'].search([ + ('product_id', '=', product_A.id), + ('state', '=', 'confirmed') + ]) + self.assertEqual(production_A.product_uom_qty, 100, "100 units of A should be scheduled for production") + production_B = self.env['mrp.production'].search([ + ('product_id', '=', product_B.id), + ('state', '=', 'confirmed') + ]) + self.assertEqual(sum(production_B.mapped('product_uom_qty')), 400, "400 units of B should be scheduled for production") + + def test_procurement_3(self): + warehouse = self.env['stock.warehouse'].search([], limit=1) + warehouse.write({'reception_steps': 'three_steps'}) + warehouse.mto_pull_id.route_id.active = True + self.env['stock.location']._parent_store_compute() + warehouse.reception_route_id.rule_ids.filtered( + lambda p: p.location_src_id == warehouse.wh_input_stock_loc_id and + p.location_id == warehouse.wh_qc_stock_loc_id).write({ + 'procure_method': 'make_to_stock' + }) + + finished_product = self.env['product.product'].create({ + 'name': 'Finished Product', + 'type': 'product', + }) + component = self.env['product.product'].create({ + 'name': 'Component', + 'type': 'product', + 'route_ids': [(4, warehouse.mto_pull_id.route_id.id)] + }) + self.env['stock.quant']._update_available_quantity(component, warehouse.wh_input_stock_loc_id, 100) + bom = self.env['mrp.bom'].create({ + 'product_id': finished_product.id, + 'product_tmpl_id': finished_product.product_tmpl_id.id, + 'product_uom_id': self.uom_unit.id, + 'product_qty': 1.0, + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': component.id, 'product_qty': 1.0}) + ]}) + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = finished_product + mo_form.bom_id = bom + mo_form.product_qty = 5 + mo_form.product_uom_id = finished_product.uom_id + mo_form.location_src_id = warehouse.lot_stock_id + mo = mo_form.save() + mo.action_confirm() + pickings = self.env['stock.picking'].search([('product_id', '=', component.id)]) + self.assertEqual(len(pickings), 2.0) + picking_input_to_qc = pickings.filtered(lambda p: p.location_id == warehouse.wh_input_stock_loc_id) + picking_qc_to_stock = pickings - picking_input_to_qc + self.assertTrue(picking_input_to_qc) + self.assertTrue(picking_qc_to_stock) + picking_input_to_qc.action_assign() + self.assertEqual(picking_input_to_qc.state, 'assigned') + picking_input_to_qc.move_line_ids.write({'qty_done': 5.0}) + picking_input_to_qc._action_done() + picking_qc_to_stock.action_assign() + self.assertEqual(picking_qc_to_stock.state, 'assigned') + picking_qc_to_stock.move_line_ids.write({'qty_done': 3.0}) + picking_qc_to_stock.with_context(skip_backorder=True, picking_ids_not_to_backorder=picking_qc_to_stock.ids).button_validate() + self.assertEqual(picking_qc_to_stock.state, 'done') + mo.action_assign() + self.assertEqual(mo.move_raw_ids.reserved_availability, 3.0) + produce_form = Form(mo) + produce_form.qty_producing = 3.0 + mo = produce_form.save() + self.assertEqual(mo.move_raw_ids.quantity_done, 3.0) + picking_qc_to_stock.move_line_ids.qty_done = 5.0 + self.assertEqual(mo.move_raw_ids.reserved_availability, 5.0) + self.assertEqual(mo.move_raw_ids.quantity_done, 3.0) + + def test_link_date_mo_moves(self): + """ Check link of shedule date for manufaturing with date stock move.""" + + # create a product with manufacture route + product_1 = self.env['product.product'].create({ + 'name': 'AAA', + 'route_ids': [(4, self.ref('mrp.route_warehouse0_manufacture'))] + }) + + component_1 = self.env['product.product'].create({ + 'name': 'component', + }) + + self.env['mrp.bom'].create({ + 'product_id': product_1.id, + 'product_tmpl_id': product_1.product_tmpl_id.id, + 'product_uom_id': self.uom_unit.id, + 'product_qty': 1.0, + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': component_1.id, 'product_qty': 1}), + ]}) + + # create a move for product_1 from stock to output and reserve to trigger the + # rule + move_dest = self.env['stock.move'].create({ + 'name': 'move_orig', + 'product_id': product_1.id, + 'product_uom': self.ref('uom.product_uom_unit'), + 'location_id': self.ref('stock.stock_location_stock'), + 'location_dest_id': self.ref('stock.stock_location_output'), + 'product_uom_qty': 10, + 'procure_method': 'make_to_order' + }) + + move_dest._action_confirm() + mo = self.env['mrp.production'].search([ + ('product_id', '=', product_1.id), + ('state', '=', 'confirmed') + ]) + + self.assertAlmostEqual(mo.move_finished_ids.date, mo.move_raw_ids.date + timedelta(hours=1), delta=timedelta(seconds=1)) + + self.assertEqual(len(mo), 1, 'the manufacture order is not created') + + mo_form = Form(mo) + self.assertEqual(mo_form.product_qty, 10, 'the quantity to produce is not good relative to the move') + + mo = mo_form.save() + + # Confirming mo create finished move + move_orig = self.env['stock.move'].search([ + ('move_dest_ids', 'in', move_dest.ids) + ], limit=1) + + self.assertEqual(len(move_orig), 1, 'the move orig is not created') + 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() + + 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)) + + def test_finished_move_cancellation(self): + """Check state of finished move on cancellation of raw moves. """ + product_bottle = self.env['product.product'].create({ + 'name': 'Plastic Bottle', + 'route_ids': [(4, self.ref('mrp.route_warehouse0_manufacture'))] + }) + + component_mold = self.env['product.product'].create({ + 'name': 'Plastic Mold', + }) + + self.env['mrp.bom'].create({ + 'product_id': product_bottle.id, + 'product_tmpl_id': product_bottle.product_tmpl_id.id, + 'product_uom_id': self.uom_unit.id, + 'product_qty': 1.0, + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': component_mold.id, 'product_qty': 1}), + ]}) + + move_dest = self.env['stock.move'].create({ + 'name': 'move_bottle', + 'product_id': product_bottle.id, + 'product_uom': self.ref('uom.product_uom_unit'), + 'location_id': self.ref('stock.stock_location_stock'), + 'location_dest_id': self.ref('stock.stock_location_output'), + 'product_uom_qty': 10, + 'procure_method': 'make_to_order', + }) + + move_dest._action_confirm() + mo = self.env['mrp.production'].search([ + ('product_id', '=', product_bottle.id), + ('state', '=', 'confirmed') + ]) + mo.move_raw_ids[0]._action_cancel() + self.assertEqual(mo.state, 'cancel', 'Manufacturing order should be cancelled.') + self.assertEqual(mo.move_finished_ids[0].state, 'cancel', 'Finished move should be cancelled if mo is cancelled.') + self.assertEqual(mo.move_dest_ids[0].state, 'waiting', 'Destination move should not be cancelled if prapogation cancel is False on manufacturing rule.') + + def test_procurement_with_empty_bom(self): + """Ensure that a procurement request using a product with an empty BoM + will create a MO in draft state that could be completed afterwards. + """ + self.warehouse = self.env.ref('stock.warehouse0') + route_manufacture = self.warehouse.manufacture_pull_id.route_id.id + route_mto = self.warehouse.mto_pull_id.route_id.id + product = self.env['product.product'].create({ + 'name': 'Clafoutis', + 'route_ids': [(6, 0, [route_manufacture, route_mto])] + }) + self.env['mrp.bom'].create({ + 'product_id': product.id, + 'product_tmpl_id': product.product_tmpl_id.id, + 'product_uom_id': self.uom_unit.id, + 'product_qty': 1.0, + 'type': 'normal', + }) + move_dest = self.env['stock.move'].create({ + 'name': 'Customer MTO Move', + 'product_id': product.id, + 'product_uom': self.ref('uom.product_uom_unit'), + 'location_id': self.ref('stock.stock_location_stock'), + 'location_dest_id': self.ref('stock.stock_location_output'), + 'product_uom_qty': 10, + 'procure_method': 'make_to_order', + }) + move_dest._action_confirm() + + production = self.env['mrp.production'].search([('product_id', '=', product.id)]) + self.assertTrue(production) + self.assertFalse(production.move_raw_ids) + self.assertEqual(production.state, 'draft') + + comp1 = self.env['product.product'].create({ + 'name': 'egg', + }) + move_values = production._get_move_raw_values(comp1, 40.0, self.env.ref('uom.product_uom_unit')) + self.env['stock.move'].create(move_values) + + production.action_confirm() + produce_form = Form(production) + produce_form.qty_producing = production.product_qty + production = produce_form.save() + production.button_mark_done() + + move_dest._action_assign() + self.assertEqual(move_dest.reserved_availability, 10.0) + + def test_auto_assign(self): + """ When auto reordering rule exists, check for when: + 1. There is not enough of a manufactured product to assign (reserve for) a picking => auto-create 1st MO + 2. There is not enough of a manufactured component to assign the created MO => auto-create 2nd MO + 3. Add an extra manufactured component (not in stock) to 1st MO => auto-create 3rd MO + 4. When 2nd MO is completed => auto-assign to 1st MO + 5. When 1st MO is completed => auto-assign to picking """ + + self.warehouse = self.env.ref('stock.warehouse0') + route_manufacture = self.warehouse.manufacture_pull_id.route_id + + product_1 = self.env['product.product'].create({ + 'name': 'Cake', + 'type': 'product', + 'route_ids': [(6, 0, [route_manufacture.id])] + }) + product_2 = self.env['product.product'].create({ + 'name': 'Cake Mix', + 'type': 'product', + 'route_ids': [(6, 0, [route_manufacture.id])] + }) + product_3 = self.env['product.product'].create({ + 'name': 'Flour', + 'type': 'consu', + }) + + self.env['mrp.bom'].create({ + 'product_id': product_1.id, + 'product_tmpl_id': product_1.product_tmpl_id.id, + 'product_uom_id': self.uom_unit.id, + 'product_qty': 1, + 'consumption': 'flexible', + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': product_2.id, 'product_qty': 1}), + ]}) + + self.env['mrp.bom'].create({ + 'product_id': product_2.id, + 'product_tmpl_id': product_2.product_tmpl_id.id, + 'product_uom_id': self.uom_unit.id, + 'product_qty': 1, + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': product_3.id, 'product_qty': 1}), + ]}) + + # extra manufactured component added to 1st MO after it is already confirmed + product_4 = self.env['product.product'].create({ + 'name': 'Flavor Enchancer', + 'type': 'product', + 'route_ids': [(6, 0, [route_manufacture.id])] + }) + product_5 = self.env['product.product'].create({ + 'name': 'MSG', + 'type': 'consu', + }) + + self.env['mrp.bom'].create({ + 'product_id': product_4.id, + 'product_tmpl_id': product_4.product_tmpl_id.id, + 'product_uom_id': self.uom_unit.id, + 'product_qty': 1, + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': product_5.id, 'product_qty': 1}), + ]}) + + # setup auto orderpoints (reordering rules) + self.env['stock.warehouse.orderpoint'].create({ + 'name': 'Cake RR', + 'location_id': self.warehouse.lot_stock_id.id, + 'product_id': product_1.id, + 'product_min_qty': 0, + 'product_max_qty': 5, + }) + + self.env['stock.warehouse.orderpoint'].create({ + 'name': 'Cake Mix RR', + 'location_id': self.warehouse.lot_stock_id.id, + 'product_id': product_2.id, + 'product_min_qty': 0, + 'product_max_qty': 5, + }) + + self.env['stock.warehouse.orderpoint'].create({ + 'name': 'Flavor Enchancer RR', + 'location_id': self.warehouse.lot_stock_id.id, + 'product_id': product_4.id, + 'product_min_qty': 0, + 'product_max_qty': 5, + }) + + # create picking output to trigger creating MO for reordering product_1 + pick_output = self.env['stock.picking'].create({ + 'name': 'Cake Delivery Order', + 'picking_type_id': self.ref('stock.picking_type_out'), + 'location_id': self.warehouse.lot_stock_id.id, + 'location_dest_id': self.ref('stock.stock_location_customers'), + 'move_lines': [(0, 0, { + 'name': '/', + 'product_id': product_1.id, + 'product_uom': product_1.uom_id.id, + 'product_uom_qty': 10.00, + 'procure_method': 'make_to_stock', + })], + }) + pick_output.action_confirm() # should trigger orderpoint to create and confirm 1st MO + pick_output.action_assign() + + mo = self.env['mrp.production'].search([ + ('product_id', '=', product_1.id), + ('state', '=', 'confirmed') + ]) + + self.assertEqual(len(mo), 1, "Manufacture order was not automatically created") + mo.action_assign() + self.assertEqual(mo.move_raw_ids.reserved_availability, 0, "No components should be reserved yet") + self.assertEqual(mo.product_qty, 15, "Quantity to produce should be picking demand + reordering rule max qty") + + # 2nd MO for product_2 should have been created and confirmed when 1st MO for product_1 was confirmed + mo2 = self.env['mrp.production'].search([ + ('product_id', '=', product_2.id), + ('state', '=', 'confirmed') + ]) + + self.assertEqual(len(mo2), 1, 'Second manufacture order was not created') + self.assertEqual(mo2.product_qty, 20, "Quantity to produce should be MO's 'to consume' qty + reordering rule max qty") + mo2_form = Form(mo2) + mo2_form.qty_producing = 20 + mo2 = mo2_form.save() + mo2.button_mark_done() + + self.assertEqual(mo.move_raw_ids.reserved_availability, 15, "Components should have been auto-reserved") + + # add new component to 1st MO + mo_form = Form(mo) + with mo_form.move_raw_ids.new() as line: + line.product_id = product_4 + line.product_uom_qty = 1 + mo_form.save() # should trigger orderpoint to create and confirm 3rd MO + + mo3 = self.env['mrp.production'].search([ + ('product_id', '=', product_4.id), + ('state', '=', 'confirmed') + ]) + + self.assertEqual(len(mo3), 1, 'Third manufacture order for added component was not created') + self.assertEqual(mo3.product_qty, 6, "Quantity to produce should be 1 + reordering rule max qty") + + mo_form = Form(mo) + mo.move_raw_ids.quantity_done = 15 + mo_form.qty_producing = 15 + mo = mo_form.save() + mo.button_mark_done() + + self.assertEqual(pick_output.move_ids_without_package.reserved_availability, 10, "Completed products should have been auto-reserved in picking") diff --git a/addons/mrp/tests/test_stock.py b/addons/mrp/tests/test_stock.py new file mode 100644 index 00000000..64587e1c --- /dev/null +++ b/addons/mrp/tests/test_stock.py @@ -0,0 +1,350 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import common +from odoo.exceptions import UserError +from odoo.tests import Form + + +class TestWarehouse(common.TestMrpCommon): + def setUp(self): + super(TestWarehouse, self).setUp() + + unit = self.env.ref("uom.product_uom_unit") + self.stock_location = self.env.ref('stock.stock_location_stock') + self.depot_location = self.env['stock.location'].create({ + 'name': 'Depot', + 'usage': 'internal', + 'location_id': self.stock_location.id, + }) + self.env["stock.putaway.rule"].create({ + "location_in_id": self.stock_location.id, + "location_out_id": self.depot_location.id, + 'category_id': self.env.ref('product.product_category_all').id, + }) + mrp_workcenter = self.env['mrp.workcenter'].create({ + 'name': 'Assembly Line 1', + 'resource_calendar_id': self.env.ref('resource.resource_calendar_std').id, + }) + inventory = self.env['stock.inventory'].create({ + 'name': 'Initial inventory', + 'line_ids': [(0, 0, { + 'product_id': self.graphics_card.id, + 'product_uom_id': self.graphics_card.uom_id.id, + 'product_qty': 16.0, + 'location_id': self.stock_location_14.id, + })] + }) + inventory.action_start() + inventory.action_validate() + + self.bom_laptop = self.env['mrp.bom'].create({ + 'product_tmpl_id': self.laptop.product_tmpl_id.id, + 'product_qty': 1, + 'product_uom_id': unit.id, + 'consumption': 'flexible', + 'bom_line_ids': [(0, 0, { + 'product_id': self.graphics_card.id, + 'product_qty': 1, + 'product_uom_id': unit.id + })], + 'operation_ids': [ + (0, 0, {'name': 'Cutting Machine', 'workcenter_id': self.workcenter_1.id, 'time_cycle': 12, 'sequence': 1}), + ], + }) + + def new_mo_laptop(self): + form = Form(self.env['mrp.production']) + form.product_id = self.laptop + form.product_qty = 1 + form.bom_id = self.bom_laptop + p = form.save() + p.action_confirm() + p.action_assign() + return p + + def test_manufacturing_route(self): + warehouse_1_stock_manager = self.warehouse_1.with_user(self.user_stock_manager) + manu_rule = self.env['stock.rule'].search([ + ('action', '=', 'manufacture'), + ('warehouse_id', '=', self.warehouse_1.id)]) + self.assertEqual(self.warehouse_1.manufacture_pull_id, manu_rule) + manu_route = manu_rule.route_id + self.assertIn(manu_route, warehouse_1_stock_manager._get_all_routes()) + warehouse_1_stock_manager.write({ + 'manufacture_to_resupply': False + }) + self.assertFalse(self.warehouse_1.manufacture_pull_id.active) + self.assertFalse(self.warehouse_1.manu_type_id.active) + self.assertNotIn(manu_route, warehouse_1_stock_manager._get_all_routes()) + warehouse_1_stock_manager.write({ + 'manufacture_to_resupply': True + }) + manu_rule = self.env['stock.rule'].search([ + ('action', '=', 'manufacture'), + ('warehouse_id', '=', self.warehouse_1.id)]) + self.assertEqual(self.warehouse_1.manufacture_pull_id, manu_rule) + self.assertTrue(self.warehouse_1.manu_type_id.active) + self.assertIn(manu_route, warehouse_1_stock_manager._get_all_routes()) + + def test_manufacturing_scrap(self): + """ + Testing to do a scrap of consumed material. + """ + + # Update demo products + (self.product_4 | self.product_2).write({ + 'tracking': 'lot', + }) + + # Update Bill Of Material to remove product with phantom bom. + self.bom_3.bom_line_ids.filtered(lambda x: x.product_id == self.product_5).unlink() + + # Create Inventory Adjustment For Stick and Stone Tools with lot. + lot_product_4 = self.env['stock.production.lot'].create({ + 'name': '0000000000001', + 'product_id': self.product_4.id, + 'company_id': self.env.company.id, + }) + lot_product_2 = self.env['stock.production.lot'].create({ + 'name': '0000000000002', + 'product_id': self.product_2.id, + 'company_id': self.env.company.id, + }) + + stock_inv_product_4 = self.env['stock.inventory'].create({ + 'name': 'Stock Inventory for Stick', + 'product_ids': [(4, self.product_4.id)], + 'line_ids': [ + (0, 0, {'product_id': self.product_4.id, 'product_uom_id': self.product_4.uom_id.id, 'product_qty': 8, 'prod_lot_id': lot_product_4.id, 'location_id': self.stock_location_14.id}), + ]}) + + stock_inv_product_2 = self.env['stock.inventory'].create({ + 'name': 'Stock Inventory for Stone Tools', + 'product_ids': [(4, self.product_2.id)], + 'line_ids': [ + (0, 0, {'product_id': self.product_2.id, 'product_uom_id': self.product_2.uom_id.id, 'product_qty': 12, 'prod_lot_id': lot_product_2.id, 'location_id': self.stock_location_14.id}) + ]}) + (stock_inv_product_4 | stock_inv_product_2)._action_start() + stock_inv_product_2.action_validate() + stock_inv_product_4.action_validate() + + #Create Manufacturing order. + production_form = Form(self.env['mrp.production']) + production_form.product_id = self.product_6 + production_form.bom_id = self.bom_3 + production_form.product_qty = 12 + production_form.product_uom_id = self.product_6.uom_id + production_3 = production_form.save() + production_3.action_confirm() + production_3.action_assign() + + # Check Manufacturing order's availability. + self.assertEqual(production_3.reservation_state, 'assigned', "Production order's availability should be Available.") + + location_id = production_3.move_raw_ids.filtered(lambda x: x.state not in ('done', 'cancel')) and production_3.location_src_id.id or production_3.location_dest_id.id, + + # Scrap Product Wood without lot to check assert raise ?. + scrap_id = self.env['stock.scrap'].with_context(active_model='mrp.production', active_id=production_3.id).create({'product_id': self.product_2.id, 'scrap_qty': 1.0, 'product_uom_id': self.product_2.uom_id.id, 'location_id': location_id, 'production_id': production_3.id}) + with self.assertRaises(UserError): + scrap_id.do_scrap() + + # Scrap Product Wood with lot. + self.env['stock.scrap'].with_context(active_model='mrp.production', active_id=production_3.id).create({'product_id': self.product_2.id, 'scrap_qty': 1.0, 'product_uom_id': self.product_2.uom_id.id, 'location_id': location_id, 'lot_id': lot_product_2.id, 'production_id': production_3.id}) + + #Check scrap move is created for this production order. + #TODO: should check with scrap objects link in between + +# scrap_move = production_3.move_raw_ids.filtered(lambda x: x.product_id == self.product_2 and x.scrapped) +# self.assertTrue(scrap_move, "There are no any scrap move created for production order.") + + def test_putaway_after_manufacturing_3(self): + """ This test checks a tracked manufactured product will go to location + defined in putaway strategy when the production is recorded with + product.produce wizard. + """ + self.laptop.tracking = 'serial' + mo_laptop = self.new_mo_laptop() + serial = self.env['stock.production.lot'].create({'product_id': self.laptop.id, 'company_id': self.env.company.id}) + + mo_form = Form(mo_laptop) + mo_form.qty_producing = 1 + mo_form.lot_producing_id = serial + mo_laptop = mo_form.save() + mo_laptop.button_mark_done() + + # We check if the laptop go in the depot and not in the stock + move = mo_laptop.move_finished_ids + location_dest = move.move_line_ids.location_dest_id + self.assertEqual(location_dest.id, self.depot_location.id) + self.assertNotEqual(location_dest.id, self.stock_location.id) + +class TestKitPicking(common.TestMrpCommon): + def setUp(self): + super(TestKitPicking, self).setUp() + + def create_product(name): + p = Form(self.env['product.product']) + p.name = name + p.type = 'product' + return p.save() + + # Create a kit 'kit_parent' : + # --------------------------- + # + # kit_parent --|- kit_2 x2 --|- component_d x1 + # | |- kit_1 x2 -------|- component_a x2 + # | |- component_b x1 + # | |- component_c x3 + # | + # |- kit_3 x1 --|- component_f x1 + # | |- component_g x2 + # | + # |- component_e x1 + # Creating all components + component_a = create_product('Comp A') + component_b = create_product('Comp B') + component_c = create_product('Comp C') + component_d = create_product('Comp D') + component_e = create_product('Comp E') + component_f = create_product('Comp F') + component_g = create_product('Comp G') + # Creating all kits + kit_1 = create_product('Kit 1') + kit_2 = create_product('Kit 2') + kit_3 = create_product('kit 3') + self.kit_parent = create_product('Kit Parent') + # Linking the kits and the components via some 'phantom' BoMs + bom_kit_1 = self.env['mrp.bom'].create({ + 'product_tmpl_id': kit_1.product_tmpl_id.id, + 'product_qty': 1.0, + 'type': 'phantom'}) + BomLine = self.env['mrp.bom.line'] + BomLine.create({ + 'product_id': component_a.id, + 'product_qty': 2.0, + 'bom_id': bom_kit_1.id}) + BomLine.create({ + 'product_id': component_b.id, + 'product_qty': 1.0, + 'bom_id': bom_kit_1.id}) + BomLine.create({ + 'product_id': component_c.id, + 'product_qty': 3.0, + 'bom_id': bom_kit_1.id}) + bom_kit_2 = self.env['mrp.bom'].create({ + 'product_tmpl_id': kit_2.product_tmpl_id.id, + 'product_qty': 1.0, + 'type': 'phantom'}) + BomLine.create({ + 'product_id': component_d.id, + 'product_qty': 1.0, + 'bom_id': bom_kit_2.id}) + BomLine.create({ + 'product_id': kit_1.id, + 'product_qty': 2.0, + 'bom_id': bom_kit_2.id}) + bom_kit_parent = self.env['mrp.bom'].create({ + 'product_tmpl_id': self.kit_parent.product_tmpl_id.id, + 'product_qty': 1.0, + 'type': 'phantom'}) + BomLine.create({ + 'product_id': component_e.id, + 'product_qty': 1.0, + 'bom_id': bom_kit_parent.id}) + BomLine.create({ + 'product_id': kit_2.id, + 'product_qty': 2.0, + 'bom_id': bom_kit_parent.id}) + bom_kit_3 = self.env['mrp.bom'].create({ + 'product_tmpl_id': kit_3.product_tmpl_id.id, + 'product_qty': 1.0, + 'type': 'phantom'}) + BomLine.create({ + 'product_id': component_f.id, + 'product_qty': 1.0, + 'bom_id': bom_kit_3.id}) + BomLine.create({ + 'product_id': component_g.id, + 'product_qty': 2.0, + 'bom_id': bom_kit_3.id}) + BomLine.create({ + 'product_id': kit_3.id, + 'product_qty': 1.0, + 'bom_id': bom_kit_parent.id}) + + # We create an 'immediate transfer' receipt for x3 kit_parent + self.test_partner = self.env['res.partner'].create({ + 'name': 'Notthat Guyagain', + }) + self.test_supplier = self.env['stock.location'].create({ + 'name': 'supplier', + 'usage': 'supplier', + 'location_id': self.env.ref('stock.stock_location_stock').id, + }) + + self.expected_quantities = { + component_a: 24, + component_b: 12, + component_c: 36, + component_d: 6, + component_e: 3, + component_f: 3, + component_g: 6 + } + + def test_kit_immediate_transfer(self): + """ Make sure a kit is split in the corrects quantity_done by components in case of an + immediate transfer. + """ + picking = self.env['stock.picking'].create({ + 'location_id': self.test_supplier.id, + 'location_dest_id': self.warehouse_1.wh_input_stock_loc_id.id, + 'partner_id': self.test_partner.id, + 'picking_type_id': self.env.ref('stock.picking_type_in').id, + 'immediate_transfer': True + }) + move_receipt_1 = self.env['stock.move'].create({ + 'name': self.kit_parent.name, + 'product_id': self.kit_parent.id, + 'quantity_done': 3, + 'product_uom': self.kit_parent.uom_id.id, + 'picking_id': picking.id, + 'picking_type_id': self.env.ref('stock.picking_type_in').id, + 'location_id': self.test_supplier.id, + 'location_dest_id': self.warehouse_1.wh_input_stock_loc_id.id, + }) + picking.button_validate() + + # We check that the picking has the correct quantities after its move were splitted. + self.assertEqual(len(picking.move_lines), 7) + for move_line in picking.move_lines: + self.assertEqual(move_line.quantity_done, self.expected_quantities[move_line.product_id]) + + def test_kit_planned_transfer(self): + """ Make sure a kit is split in the corrects product_qty by components in case of a + planned transfer. + """ + picking = self.env['stock.picking'].create({ + 'location_id': self.test_supplier.id, + 'location_dest_id': self.warehouse_1.wh_input_stock_loc_id.id, + 'partner_id': self.test_partner.id, + 'picking_type_id': self.env.ref('stock.picking_type_in').id, + 'immediate_transfer': False, + }) + move_receipt_1 = self.env['stock.move'].create({ + 'name': self.kit_parent.name, + 'product_id': self.kit_parent.id, + 'product_uom_qty': 3, + 'product_uom': self.kit_parent.uom_id.id, + 'picking_id': picking.id, + 'picking_type_id': self.env.ref('stock.picking_type_in').id, + 'location_id': self.test_supplier.id, + 'location_dest_id': self.warehouse_1.wh_input_stock_loc_id.id, + }) + picking.action_confirm() + + # We check that the picking has the correct quantities after its move were splitted. + self.assertEqual(len(picking.move_lines), 7) + for move_line in picking.move_lines: + self.assertEqual(move_line.product_qty, self.expected_quantities[move_line.product_id]) diff --git a/addons/mrp/tests/test_stock_report.py b/addons/mrp/tests/test_stock_report.py new file mode 100644 index 00000000..ce97fad2 --- /dev/null +++ b/addons/mrp/tests/test_stock_report.py @@ -0,0 +1,153 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests.common import Form +from odoo.addons.stock.tests.test_report import TestReportsCommon + + +class TestSaleStockReports(TestReportsCommon): + def test_report_forecast_1_mo_count(self): + """ Creates and configures a product who could be produce and could be a component. + Plans some producing and consumming MO and check the report values. + """ + # Create a variant attribute. + product_chocolate = self.env['product.product'].create({ + 'name': 'Chocolate', + 'type': 'consu', + }) + product_chococake = self.env['product.product'].create({ + 'name': 'Choco Cake', + 'type': 'product', + }) + product_double_chococake = self.env['product.product'].create({ + 'name': 'Double Choco Cake', + 'type': 'product', + }) + + # Creates two BOM: one creating a regular slime, one using regular slimes. + bom_chococake = self.env['mrp.bom'].create({ + 'product_id': product_chococake.id, + 'product_tmpl_id': product_chococake.product_tmpl_id.id, + 'product_uom_id': product_chococake.uom_id.id, + 'product_qty': 1.0, + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': product_chocolate.id, 'product_qty': 4}), + ], + }) + bom_double_chococake = self.env['mrp.bom'].create({ + 'product_id': product_double_chococake.id, + 'product_tmpl_id': product_double_chococake.product_tmpl_id.id, + 'product_uom_id': product_double_chococake.uom_id.id, + 'product_qty': 1.0, + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': product_chococake.id, 'product_qty': 2}), + ], + }) + + # Creates two MO: one for each BOM. + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = product_chococake + mo_form.bom_id = bom_chococake + mo_form.product_qty = 10 + mo_1 = mo_form.save() + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = product_double_chococake + mo_form.bom_id = bom_double_chococake + mo_form.product_qty = 2 + mo_2 = mo_form.save() + + report_values, docs, lines = self.get_report_forecast(product_template_ids=product_chococake.product_tmpl_id.ids) + draft_picking_qty = docs['draft_picking_qty'] + draft_production_qty = docs['draft_production_qty'] + self.assertEqual(len(lines), 0, "Must have 0 line.") + self.assertEqual(draft_picking_qty['in'], 0) + self.assertEqual(draft_picking_qty['out'], 0) + self.assertEqual(draft_production_qty['in'], 10) + self.assertEqual(draft_production_qty['out'], 4) + + # Confirms the MO and checks the report lines. + mo_1.action_confirm() + mo_2.action_confirm() + report_values, docs, lines = self.get_report_forecast(product_template_ids=product_chococake.product_tmpl_id.ids) + draft_picking_qty = docs['draft_picking_qty'] + draft_production_qty = docs['draft_production_qty'] + self.assertEqual(len(lines), 2, "Must have two line.") + line_1 = lines[0] + line_2 = lines[1] + self.assertEqual(line_1['document_in'].id, mo_1.id) + self.assertEqual(line_1['quantity'], 4) + self.assertEqual(line_1['document_out'].id, mo_2.id) + self.assertEqual(line_2['document_in'].id, mo_1.id) + self.assertEqual(line_2['quantity'], 6) + self.assertEqual(line_2['document_out'], False) + self.assertEqual(draft_picking_qty['in'], 0) + self.assertEqual(draft_picking_qty['out'], 0) + self.assertEqual(draft_production_qty['in'], 0) + self.assertEqual(draft_production_qty['out'], 0) + + def test_report_forecast_2_production_backorder(self): + """ Creates a manufacturing order and produces half the quantity. + Then creates a backorder and checks the report. + """ + # Configures the warehouse. + warehouse = self.env.ref('stock.warehouse0') + warehouse.manufacture_steps = 'pbm_sam' + # Configures a product. + product_apple_pie = self.env['product.product'].create({ + 'name': 'Apple Pie', + 'type': 'product', + }) + product_apple = self.env['product.product'].create({ + 'name': 'Apple', + 'type': 'consu', + }) + bom = self.env['mrp.bom'].create({ + 'product_id': product_apple_pie.id, + 'product_tmpl_id': product_apple_pie.product_tmpl_id.id, + 'product_uom_id': product_apple_pie.uom_id.id, + 'product_qty': 1.0, + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': product_apple.id, 'product_qty': 5}), + ], + }) + # Creates a MO and validates the pick components. + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = product_apple_pie + mo_form.bom_id = bom + mo_form.product_qty = 4 + mo_1 = mo_form.save() + mo_1.action_confirm() + pick = mo_1.move_raw_ids.move_orig_ids.picking_id + pick_form = Form(pick) + with pick_form.move_line_ids_without_package.edit(0) as move_line: + move_line.qty_done = 20 + pick = pick_form.save() + pick.button_validate() + # Produces 3 products then creates a backorder for the remaining product. + mo_form = Form(mo_1) + mo_form.qty_producing = 3 + mo_1 = mo_form.save() + action = mo_1.button_mark_done() + backorder_form = Form(self.env['mrp.production.backorder'].with_context(**action['context'])) + backorder = backorder_form.save() + backorder.action_backorder() + + mo_2 = (mo_1.procurement_group_id.mrp_production_ids - mo_1) + # Checks the forecast report. + report_values, docs, lines = self.get_report_forecast(product_template_ids=product_apple_pie.product_tmpl_id.ids) + self.assertEqual(len(lines), 1, "Must have only one line about the backorder") + self.assertEqual(lines[0]['document_in'].id, mo_2.id) + self.assertEqual(lines[0]['quantity'], 1) + self.assertEqual(lines[0]['document_out'], False) + + # Produces the last unit. + mo_form = Form(mo_2) + mo_form.qty_producing = 1 + mo_2 = mo_form.save() + mo_2.button_mark_done() + # Checks the forecast report. + report_values, docs, lines = self.get_report_forecast(product_template_ids=product_apple_pie.product_tmpl_id.ids) + self.assertEqual(len(lines), 0, "Must have no line") diff --git a/addons/mrp/tests/test_traceability.py b/addons/mrp/tests/test_traceability.py new file mode 100644 index 00000000..502cac05 --- /dev/null +++ b/addons/mrp/tests/test_traceability.py @@ -0,0 +1,308 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import Form, tagged +from odoo.addons.mrp.tests.common import TestMrpCommon +import uuid + +class TestTraceability(TestMrpCommon): + TRACKING_TYPES = ['none', 'serial', 'lot'] + + def _create_product(self, tracking): + return self.env['product.product'].create({ + 'name': 'Product %s' % tracking, + 'type': 'product', + 'tracking': tracking, + 'categ_id': self.env.ref('product.product_category_all').id, + }) + + def test_tracking_types_on_mo(self): + finished_no_track = self._create_product('none') + finished_lot = self._create_product('lot') + finished_serial = self._create_product('serial') + consumed_no_track = self._create_product('none') + consumed_lot = self._create_product('lot') + consumed_serial = self._create_product('serial') + stock_id = self.env.ref('stock.stock_location_stock').id + inventory_adjustment = self.env['stock.inventory'].create({ + 'name': 'Initial Inventory', + 'location_ids': [(4, stock_id)], + }) + inventory_adjustment.action_start() + inventory_adjustment.write({ + 'line_ids': [ + (0,0, {'product_id': consumed_no_track.id, 'product_qty': 3, 'location_id': stock_id}), + (0,0, {'product_id': consumed_lot.id, 'product_qty': 3, 'prod_lot_id': self.env['stock.production.lot'].create({'name': 'L1', 'product_id': consumed_lot.id, 'company_id': self.env.company.id}).id, 'location_id': stock_id}), + (0,0, {'product_id': consumed_serial.id, 'product_qty': 1, 'prod_lot_id': self.env['stock.production.lot'].create({'name': 'S1', 'product_id': consumed_serial.id, 'company_id': self.env.company.id}).id, 'location_id': stock_id}), + (0,0, {'product_id': consumed_serial.id, 'product_qty': 1, 'prod_lot_id': self.env['stock.production.lot'].create({'name': 'S2', 'product_id': consumed_serial.id, 'company_id': self.env.company.id}).id, 'location_id': stock_id}), + (0,0, {'product_id': consumed_serial.id, 'product_qty': 1, 'prod_lot_id': self.env['stock.production.lot'].create({'name': 'S3', 'product_id': consumed_serial.id, 'company_id': self.env.company.id}).id, 'location_id': stock_id}), + ] + }) + inventory_adjustment.action_validate() + for finished_product in [finished_no_track, finished_lot, finished_serial]: + bom = self.env['mrp.bom'].create({ + 'product_id': finished_product.id, + 'product_tmpl_id': finished_product.product_tmpl_id.id, + 'product_uom_id': self.env.ref('uom.product_uom_unit').id, + 'product_qty': 1.0, + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': consumed_no_track.id, 'product_qty': 1}), + (0, 0, {'product_id': consumed_lot.id, 'product_qty': 1}), + (0, 0, {'product_id': consumed_serial.id, 'product_qty': 1}), + ], + }) + + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = finished_product + mo_form.bom_id = bom + mo_form.product_uom_id = self.env.ref('uom.product_uom_unit') + mo_form.product_qty = 1 + mo = mo_form.save() + mo.action_confirm() + mo.action_assign() + + # Start MO production + mo_form = Form(mo) + mo_form.qty_producing = 1 + if finished_product.tracking != 'none': + mo_form.lot_producing_id = self.env['stock.production.lot'].create({'name': 'Serial or Lot finished', 'product_id': finished_product.id, 'company_id': self.env.company.id}) + mo = mo_form.save() + + details_operation_form = Form(mo.move_raw_ids[1], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.edit(0) as ml: + ml.qty_done = 1 + details_operation_form.save() + details_operation_form = Form(mo.move_raw_ids[2], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.edit(0) as ml: + ml.qty_done = 1 + details_operation_form.save() + + + mo.button_mark_done() + + self.assertEqual(mo.state, 'done', "Production order should be in done state.") + + # Check results of traceability + context = ({ + 'active_id': mo.id, + 'model': 'mrp.production', + }) + lines = self.env['stock.traceability.report'].with_context(context).get_lines() + + self.assertEqual(len(lines), 1, "Should always return 1 line : the final product") + + final_product = lines[0] + self.assertEqual(final_product['unfoldable'], True, "Final product should always be unfoldable") + + # Find parts of the final products + lines = self.env['stock.traceability.report'].get_lines(final_product['id'], **{ + 'level': final_product['level'], + 'model_id': final_product['model_id'], + 'model_name': final_product['model'], + }) + + self.assertEqual(len(lines), 3, "There should be 3 lines. 1 for untracked, 1 for lot, and 1 for serial") + + for line in lines: + tracking = line['columns'][1].split(' ')[1] + self.assertEqual( + line['columns'][-1], "1.00 Units", 'Part with tracking type "%s", should have quantity = 1' % (tracking) + ) + unfoldable = False if tracking == 'none' else True + self.assertEqual( + line['unfoldable'], + unfoldable, + 'Parts with tracking type "%s", should have be unfoldable : %s' % (tracking, unfoldable) + ) + + def test_tracking_on_byproducts(self): + product_final = self.env['product.product'].create({ + 'name': 'Finished Product', + 'type': 'product', + 'tracking': 'serial', + }) + product_1 = self.env['product.product'].create({ + 'name': 'Raw 1', + 'type': 'product', + 'tracking': 'serial', + }) + product_2 = self.env['product.product'].create({ + 'name': 'Raw 2', + 'type': 'product', + 'tracking': 'serial', + }) + byproduct_1 = self.env['product.product'].create({ + 'name': 'Byproduct 1', + 'type': 'product', + 'tracking': 'serial', + }) + byproduct_2 = self.env['product.product'].create({ + 'name': 'Byproduct 2', + 'type': 'product', + 'tracking': 'serial', + }) + bom_1 = self.env['mrp.bom'].create({ + 'product_id': product_final.id, + 'product_tmpl_id': product_final.product_tmpl_id.id, + 'product_uom_id': self.uom_unit.id, + 'product_qty': 1.0, + 'consumption': 'flexible', + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': product_1.id, 'product_qty': 1}), + (0, 0, {'product_id': product_2.id, 'product_qty': 1}) + ], + 'byproduct_ids': [ + (0, 0, {'product_id': byproduct_1.id, 'product_qty': 1, 'product_uom_id': byproduct_1.uom_id.id}), + (0, 0, {'product_id': byproduct_2.id, 'product_qty': 1, 'product_uom_id': byproduct_2.uom_id.id}) + ]}) + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = product_final + mo_form.bom_id = bom_1 + mo_form.product_qty = 2 + mo = mo_form.save() + mo.action_confirm() + + mo_form = Form(mo) + mo_form.lot_producing_id = self.env['stock.production.lot'].create({ + 'product_id': product_final.id, + 'name': 'Final_lot_1', + 'company_id': self.env.company.id, + }) + mo = mo_form.save() + + details_operation_form = Form(mo.move_raw_ids[0], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.new() as ml: + ml.lot_id = self.env['stock.production.lot'].create({ + 'product_id': product_1.id, + 'name': 'Raw_1_lot_1', + 'company_id': self.env.company.id, + }) + ml.qty_done = 1 + details_operation_form.save() + details_operation_form = Form(mo.move_raw_ids[1], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.new() as ml: + ml.lot_id = self.env['stock.production.lot'].create({ + 'product_id': product_2.id, + 'name': 'Raw_2_lot_1', + 'company_id': self.env.company.id, + }) + ml.qty_done = 1 + details_operation_form.save() + details_operation_form = Form( + mo.move_finished_ids.filtered(lambda m: m.product_id == byproduct_1), + view=self.env.ref('stock.view_stock_move_operations') + ) + with details_operation_form.move_line_ids.new() as ml: + ml.lot_id = self.env['stock.production.lot'].create({ + 'product_id': byproduct_1.id, + 'name': 'Byproduct_1_lot_1', + 'company_id': self.env.company.id, + }) + ml.qty_done = 1 + details_operation_form.save() + details_operation_form = Form( + mo.move_finished_ids.filtered(lambda m: m.product_id == byproduct_2), + view=self.env.ref('stock.view_stock_move_operations') + ) + with details_operation_form.move_line_ids.new() as ml: + ml.lot_id = self.env['stock.production.lot'].create({ + 'product_id': byproduct_2.id, + 'name': 'Byproduct_2_lot_1', + 'company_id': self.env.company.id, + }) + ml.qty_done = 1 + details_operation_form.save() + + action = mo.button_mark_done() + backorder = Form(self.env['mrp.production.backorder'].with_context(**action['context'])) + backorder.save().action_backorder() + mo_backorder = mo.procurement_group_id.mrp_production_ids[-1] + mo_form = Form(mo_backorder) + mo_form.lot_producing_id = self.env['stock.production.lot'].create({ + 'product_id': product_final.id, + 'name': 'Final_lot_2', + 'company_id': self.env.company.id, + }) + mo_form.qty_producing = 1 + mo_backorder = mo_form.save() + + details_operation_form = Form( + mo_backorder.move_raw_ids.filtered(lambda m: m.product_id == product_1), + view=self.env.ref('stock.view_stock_move_operations') + ) + with details_operation_form.move_line_ids.new() as ml: + ml.lot_id = self.env['stock.production.lot'].create({ + 'product_id': product_1.id, + 'name': 'Raw_1_lot_2', + 'company_id': self.env.company.id, + }) + ml.qty_done = 1 + details_operation_form.save() + details_operation_form = Form( + mo_backorder.move_raw_ids.filtered(lambda m: m.product_id == product_2), + view=self.env.ref('stock.view_stock_move_operations') + ) + with details_operation_form.move_line_ids.new() as ml: + ml.lot_id = self.env['stock.production.lot'].create({ + 'product_id': product_2.id, + 'name': 'Raw_2_lot_2', + 'company_id': self.env.company.id, + }) + ml.qty_done = 1 + details_operation_form.save() + details_operation_form = Form( + mo_backorder.move_finished_ids.filtered(lambda m: m.product_id == byproduct_1), + view=self.env.ref('stock.view_stock_move_operations') + ) + with details_operation_form.move_line_ids.new() as ml: + ml.lot_id = self.env['stock.production.lot'].create({ + 'product_id': byproduct_1.id, + 'name': 'Byproduct_1_lot_2', + 'company_id': self.env.company.id, + }) + ml.qty_done = 1 + details_operation_form.save() + details_operation_form = Form( + mo_backorder.move_finished_ids.filtered(lambda m: m.product_id == byproduct_2), + view=self.env.ref('stock.view_stock_move_operations') + ) + with details_operation_form.move_line_ids.new() as ml: + ml.lot_id = self.env['stock.production.lot'].create({ + 'product_id': byproduct_2.id, + 'name': 'Byproduct_2_lot_2', + 'company_id': self.env.company.id, + }) + ml.qty_done = 1 + details_operation_form.save() + + mo_backorder.button_mark_done() + + # self.assertEqual(len(mo.move_raw_ids.mapped('move_line_ids')), 4) + # self.assertEqual(len(mo.move_finished_ids.mapped('move_line_ids')), 6) + + mo = mo | mo_backorder + raw_move_lines = mo.move_raw_ids.mapped('move_line_ids') + raw_line_raw_1_lot_1 = raw_move_lines.filtered(lambda ml: ml.lot_id.name == 'Raw_1_lot_1') + self.assertEqual(set(raw_line_raw_1_lot_1.produce_line_ids.lot_id.mapped('name')), set(['Final_lot_1', 'Byproduct_1_lot_1', 'Byproduct_2_lot_1'])) + raw_line_raw_2_lot_1 = raw_move_lines.filtered(lambda ml: ml.lot_id.name == 'Raw_2_lot_1') + self.assertEqual(set(raw_line_raw_2_lot_1.produce_line_ids.lot_id.mapped('name')), set(['Final_lot_1', 'Byproduct_1_lot_1', 'Byproduct_2_lot_1'])) + + finished_move_lines = mo.move_finished_ids.mapped('move_line_ids') + finished_move_line_lot_1 = finished_move_lines.filtered(lambda ml: ml.lot_id.name == 'Final_lot_1') + self.assertEqual(finished_move_line_lot_1.consume_line_ids.filtered(lambda l: l.qty_done), raw_line_raw_1_lot_1 | raw_line_raw_2_lot_1) + finished_move_line_lot_2 = finished_move_lines.filtered(lambda ml: ml.lot_id.name == 'Final_lot_2') + raw_line_raw_1_lot_2 = raw_move_lines.filtered(lambda ml: ml.lot_id.name == 'Raw_1_lot_2') + raw_line_raw_2_lot_2 = raw_move_lines.filtered(lambda ml: ml.lot_id.name == 'Raw_2_lot_2') + self.assertEqual(finished_move_line_lot_2.consume_line_ids, raw_line_raw_1_lot_2 | raw_line_raw_2_lot_2) + + byproduct_move_line_1_lot_1 = finished_move_lines.filtered(lambda ml: ml.lot_id.name == 'Byproduct_1_lot_1') + self.assertEqual(byproduct_move_line_1_lot_1.consume_line_ids.filtered(lambda l: l.qty_done), raw_line_raw_1_lot_1 | raw_line_raw_2_lot_1) + byproduct_move_line_1_lot_2 = finished_move_lines.filtered(lambda ml: ml.lot_id.name == 'Byproduct_1_lot_2') + self.assertEqual(byproduct_move_line_1_lot_2.consume_line_ids, raw_line_raw_1_lot_2 | raw_line_raw_2_lot_2) + + byproduct_move_line_2_lot_1 = finished_move_lines.filtered(lambda ml: ml.lot_id.name == 'Byproduct_2_lot_1') + self.assertEqual(byproduct_move_line_2_lot_1.consume_line_ids.filtered(lambda l: l.qty_done), raw_line_raw_1_lot_1 | raw_line_raw_2_lot_1) + byproduct_move_line_2_lot_2 = finished_move_lines.filtered(lambda ml: ml.lot_id.name == 'Byproduct_2_lot_2') + self.assertEqual(byproduct_move_line_2_lot_2.consume_line_ids, raw_line_raw_1_lot_2 | raw_line_raw_2_lot_2) diff --git a/addons/mrp/tests/test_unbuild.py b/addons/mrp/tests/test_unbuild.py new file mode 100644 index 00000000..159b4c3d --- /dev/null +++ b/addons/mrp/tests/test_unbuild.py @@ -0,0 +1,599 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import Form +from odoo.addons.mrp.tests.common import TestMrpCommon +from odoo.exceptions import UserError + + +class TestUnbuild(TestMrpCommon): + def setUp(self): + super(TestUnbuild, self).setUp() + self.stock_location = self.env.ref('stock.stock_location_stock') + self.env.ref('base.group_user').write({ + 'implied_ids': [(4, self.env.ref('stock.group_production_lot').id)] + }) + + def test_unbuild_standart(self): + """ This test creates a MO and then creates 3 unbuild + orders for the final product. None of the products for this + test are tracked. It checks the stock state after each order + and ensure it is correct. + """ + mo, bom, p_final, p1, p2 = self.generate_mo() + self.assertEqual(len(mo), 1, 'MO should have been created') + + self.env['stock.quant']._update_available_quantity(p1, self.stock_location, 100) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 5) + mo.action_assign() + + mo_form = Form(mo) + mo_form.qty_producing = 5.0 + mo = mo_form.save() + mo.button_mark_done() + self.assertEqual(mo.state, 'done', "Production order should be in done state.") + + # Check quantity in stock before unbuild. + self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location), 5, 'You should have the 5 final product in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location), 80, 'You should have 80 products in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location), 0, 'You should have consumed all the 5 product in stock') + + # --------------------------------------------------- + # unbuild + # --------------------------------------------------- + + x = Form(self.env['mrp.unbuild']) + x.product_id = p_final + x.bom_id = bom + x.product_qty = 3 + x.save().action_unbuild() + + + self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location), 2, 'You should have consumed 3 final product in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location), 92, 'You should have 80 products in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location), 3, 'You should have consumed all the 5 product in stock') + + x = Form(self.env['mrp.unbuild']) + x.product_id = p_final + x.bom_id = bom + x.product_qty = 2 + x.save().action_unbuild() + + self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location), 0, 'You should have 0 finalproduct in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location), 100, 'You should have 80 products in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location), 5, 'You should have consumed all the 5 product in stock') + + x = Form(self.env['mrp.unbuild']) + x.product_id = p_final + x.bom_id = bom + x.product_qty = 5 + x.save().action_unbuild() + + # Check quantity in stock after last unbuild. + self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location, allow_negative=True), -5, 'You should have negative quantity for final product in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location), 120, 'You should have 80 products in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location), 10, 'You should have consumed all the 5 product in stock') + + def test_unbuild_with_final_lot(self): + """ This test creates a MO and then creates 3 unbuild + orders for the final product. Only the final product is tracked + by lot. It checks the stock state after each order + and ensure it is correct. + """ + mo, bom, p_final, p1, p2 = self.generate_mo(tracking_final='lot') + self.assertEqual(len(mo), 1, 'MO should have been created') + + lot = self.env['stock.production.lot'].create({ + 'name': 'lot1', + 'product_id': p_final.id, + 'company_id': self.env.company.id, + }) + + self.env['stock.quant']._update_available_quantity(p1, self.stock_location, 100) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 5) + mo.action_assign() + + mo_form = Form(mo) + mo_form.qty_producing = 5.0 + mo_form.lot_producing_id = lot + mo = mo_form.save() + + mo.button_mark_done() + self.assertEqual(mo.state, 'done', "Production order should be in done state.") + + # Check quantity in stock before unbuild. + self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location, lot_id=lot), 5, 'You should have the 5 final product in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location), 80, 'You should have 80 products in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location), 0, 'You should have consumed all the 5 product in stock') + + # --------------------------------------------------- + # unbuild + # --------------------------------------------------- + + # This should fail since we do not choose a lot to unbuild for final product. + with self.assertRaises(AssertionError): + x = Form(self.env['mrp.unbuild']) + x.product_id = p_final + x.bom_id = bom + x.product_qty = 3 + unbuild_order = x.save() + + x = Form(self.env['mrp.unbuild']) + x.product_id = p_final + x.bom_id = bom + x.product_qty = 3 + x.lot_id = lot + x.save().action_unbuild() + + self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location, lot_id=lot), 2, 'You should have consumed 3 final product in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location), 92, 'You should have 80 products in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location), 3, 'You should have consumed all the 5 product in stock') + + x = Form(self.env['mrp.unbuild']) + x.product_id = p_final + x.bom_id = bom + x.product_qty = 2 + x.lot_id = lot + x.save().action_unbuild() + + self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location, lot_id=lot), 0, 'You should have 0 finalproduct in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location), 100, 'You should have 80 products in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location), 5, 'You should have consumed all the 5 product in stock') + + x = Form(self.env['mrp.unbuild']) + x.product_id = p_final + x.bom_id = bom + x.product_qty = 5 + x.lot_id = lot + x.save().action_unbuild() + + self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location, lot_id=lot, allow_negative=True), -5, 'You should have negative quantity for final product in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location), 120, 'You should have 80 products in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location), 10, 'You should have consumed all the 5 product in stock') + + def test_unbuild_with_comnsumed_lot(self): + """ This test creates a MO and then creates 3 unbuild + orders for the final product. Only once of the two consumed + product is tracked by lot. It checks the stock state after each + order and ensure it is correct. + """ + mo, bom, p_final, p1, p2 = self.generate_mo(tracking_base_1='lot') + self.assertEqual(len(mo), 1, 'MO should have been created') + + lot = self.env['stock.production.lot'].create({ + 'name': 'lot1', + 'product_id': p1.id, + 'company_id': self.env.company.id, + }) + + self.env['stock.quant']._update_available_quantity(p1, self.stock_location, 100, lot_id=lot) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 5) + mo.action_assign() + for ml in mo.move_raw_ids.mapped('move_line_ids'): + if ml.product_id.tracking != 'none': + self.assertEqual(ml.lot_id, lot, 'Wrong reserved lot.') + + # FIXME sle: behavior change + mo_form = Form(mo) + mo_form.qty_producing = 5.0 + mo = mo_form.save() + details_operation_form = Form(mo.move_raw_ids[1], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.edit(0) as ml: + ml.lot_id = lot + ml.qty_done = 20 + details_operation_form.save() + + mo.button_mark_done() + self.assertEqual(mo.state, 'done', "Production order should be in done state.") + # Check quantity in stock before unbuild. + self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location), 5, 'You should have the 5 final product in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location, lot_id=lot), 80, 'You should have 80 products in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location), 0, 'You should have consumed all the 5 product in stock') + + # --------------------------------------------------- + # unbuild + # --------------------------------------------------- + + x = Form(self.env['mrp.unbuild']) + x.product_id = p_final + x.bom_id = bom + x.product_qty = 3 + unbuild_order = x.save() + + # This should fail since we do not provide the MO that we wanted to unbuild. (without MO we do not know which consumed lot we have to restore) + with self.assertRaises(UserError): + unbuild_order.action_unbuild() + + self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location), 5, 'You should have consumed 3 final product in stock') + + unbuild_order.mo_id = mo.id + unbuild_order.action_unbuild() + + self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location), 2, 'You should have consumed 3 final product in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location, lot_id=lot), 92, 'You should have 92 products in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location), 3, 'You should have consumed all the 5 product in stock') + + x = Form(self.env['mrp.unbuild']) + x.product_id = p_final + x.bom_id = bom + x.mo_id = mo + x.product_qty = 2 + x.save().action_unbuild() + + self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location), 0, 'You should have 0 finalproduct in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location, lot_id=lot), 100, 'You should have 80 products in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location), 5, 'You should have consumed all the 5 product in stock') + + x = Form(self.env['mrp.unbuild']) + x.product_id = p_final + x.bom_id = bom + x.mo_id = mo + x.product_qty = 5 + x.save().action_unbuild() + + self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location, allow_negative=True), -5, 'You should have negative quantity for final product in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location, lot_id=lot), 120, 'You should have 80 products in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location), 10, 'You should have consumed all the 5 product in stock') + + def test_unbuild_with_everything_tracked(self): + """ This test creates a MO and then creates 3 unbuild + orders for the final product. All the products for this + test are tracked. It checks the stock state after each order + and ensure it is correct. + """ + mo, bom, p_final, p1, p2 = self.generate_mo(tracking_final='lot', tracking_base_2='lot', tracking_base_1='lot') + self.assertEqual(len(mo), 1, 'MO should have been created') + + lot_final = self.env['stock.production.lot'].create({ + 'name': 'lot_final', + 'product_id': p_final.id, + 'company_id': self.env.company.id, + }) + lot_1 = self.env['stock.production.lot'].create({ + 'name': 'lot_consumed_1', + 'product_id': p1.id, + 'company_id': self.env.company.id, + }) + lot_2 = self.env['stock.production.lot'].create({ + 'name': 'lot_consumed_2', + 'product_id': p2.id, + 'company_id': self.env.company.id, + }) + + self.env['stock.quant']._update_available_quantity(p1, self.stock_location, 100, lot_id=lot_1) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 5, lot_id=lot_2) + mo.action_assign() + + # FIXME sle: behavior change + mo_form = Form(mo) + mo_form.qty_producing = 5.0 + mo_form.lot_producing_id = lot_final + mo = mo_form.save() + details_operation_form = Form(mo.move_raw_ids[0], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.edit(0) as ml: + ml.qty_done = 5 + details_operation_form.save() + details_operation_form = Form(mo.move_raw_ids[1], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.edit(0) as ml: + ml.qty_done = 20 + details_operation_form.save() + + mo.button_mark_done() + self.assertEqual(mo.state, 'done', "Production order should be in done state.") + # Check quantity in stock before unbuild. + self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location, lot_id=lot_final), 5, 'You should have the 5 final product in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location, lot_id=lot_1), 80, 'You should have 80 products in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location, lot_id=lot_2), 0, 'You should have consumed all the 5 product in stock') + + # --------------------------------------------------- + # unbuild + # --------------------------------------------------- + + x = Form(self.env['mrp.unbuild']) + with self.assertRaises(AssertionError): + x.product_id = p_final + x.bom_id = bom + x.product_qty = 3 + x.save() + + with self.assertRaises(AssertionError): + x.product_id = p_final + x.bom_id = bom + x.product_qty = 3 + x.save() + + self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location, lot_id=lot_final), 5, 'You should have consumed 3 final product in stock') + + with self.assertRaises(AssertionError): + x.product_id = p_final + x.bom_id = bom + x.mo_id = mo + x.product_qty = 3 + x.save() + + self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location, lot_id=lot_final), 5, 'You should have consumed 3 final product in stock') + + x = Form(self.env['mrp.unbuild']) + x.product_id = p_final + x.bom_id = bom + x.mo_id = mo + x.product_qty = 3 + x.lot_id = lot_final + x.save().action_unbuild() + + self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location, lot_id=lot_final), 2, 'You should have consumed 3 final product in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location, lot_id=lot_1), 92, 'You should have 92 products in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location, lot_id=lot_2), 3, 'You should have consumed all the 5 product in stock') + + x = Form(self.env['mrp.unbuild']) + x.product_id = p_final + x.bom_id = bom + x.mo_id = mo + x.product_qty = 2 + x.lot_id = lot_final + x.save().action_unbuild() + + self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location, lot_id=lot_final), 0, 'You should have 0 finalproduct in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location, lot_id=lot_1), 100, 'You should have 80 products in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location, lot_id=lot_2), 5, 'You should have consumed all the 5 product in stock') + + x = Form(self.env['mrp.unbuild']) + x.product_id = p_final + x.bom_id = bom + x.mo_id = mo + x.product_qty = 5 + x.lot_id = lot_final + x.save().action_unbuild() + + self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location, lot_id=lot_final, allow_negative=True), -5, 'You should have negative quantity for final product in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location, lot_id=lot_1), 120, 'You should have 80 products in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location, lot_id=lot_2), 10, 'You should have consumed all the 5 product in stock') + + def test_unbuild_with_duplicate_move(self): + """ This test creates a MO from 3 different lot on a consumed product (p2). + The unbuild order should revert the correct quantity for each specific lot. + """ + mo, bom, p_final, p1, p2 = self.generate_mo(tracking_final='none', tracking_base_2='lot', tracking_base_1='none') + self.assertEqual(len(mo), 1, 'MO should have been created') + + lot_1 = self.env['stock.production.lot'].create({ + 'name': 'lot_1', + 'product_id': p2.id, + 'company_id': self.env.company.id, + }) + lot_2 = self.env['stock.production.lot'].create({ + 'name': 'lot_2', + 'product_id': p2.id, + 'company_id': self.env.company.id, + }) + lot_3 = self.env['stock.production.lot'].create({ + 'name': 'lot_3', + 'product_id': p2.id, + 'company_id': self.env.company.id, + }) + self.env['stock.quant']._update_available_quantity(p1, self.stock_location, 100) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 1, lot_id=lot_1) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 3, lot_id=lot_2) + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 2, lot_id=lot_3) + mo.action_assign() + + mo_form = Form(mo) + mo_form.qty_producing = 5.0 + mo = mo_form.save() + details_operation_form = Form(mo.move_raw_ids.filtered(lambda ml: ml.product_id == p2), view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.edit(0) as ml: + ml.qty_done = ml.product_uom_qty + with details_operation_form.move_line_ids.edit(1) as ml: + ml.qty_done = ml.product_uom_qty + with details_operation_form.move_line_ids.edit(2) as ml: + ml.qty_done = ml.product_uom_qty + details_operation_form.save() + + mo.button_mark_done() + self.assertEqual(mo.state, 'done', "Production order should be in done state.") + # Check quantity in stock before unbuild. + self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location), 5, 'You should have the 5 final product in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location), 80, 'You should have 80 products in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location, lot_id=lot_1), 0, 'You should have consumed all the 1 product for lot 1 in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location, lot_id=lot_2), 0, 'You should have consumed all the 3 product for lot 2 in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location, lot_id=lot_3), 1, 'You should have consumed only 1 product for lot3 in stock') + + x = Form(self.env['mrp.unbuild']) + x.product_id = p_final + x.bom_id = bom + x.mo_id = mo + x.product_qty = 5 + x.save().action_unbuild() + + self.assertEqual(self.env['stock.quant']._get_available_quantity(p_final, self.stock_location), 0, 'You should have no more final product in stock after unbuild') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p1, self.stock_location), 100, 'You should have 80 products in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location, lot_id=lot_1), 1, 'You should have get your product with lot 1 in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location, lot_id=lot_2), 3, 'You should have the 3 basic product for lot 2 in stock') + self.assertEqual(self.env['stock.quant']._get_available_quantity(p2, self.stock_location, lot_id=lot_3), 2, 'You should have get one product back for lot 3') + + def test_production_links_with_non_tracked_lots(self): + """ This test produces an MO in two times and checks that the move lines are linked in a correct way + """ + mo, bom, p_final, p1, p2 = self.generate_mo(tracking_final='lot', tracking_base_1='none', tracking_base_2='lot') + # Young Tom + # \ Botox - 4 - p1 + # \ Old Tom - 1 - p2 + lot_1 = self.env['stock.production.lot'].create({ + 'name': 'lot_1', + 'product_id': p2.id, + 'company_id': self.env.company.id, + }) + + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 3, lot_id=lot_1) + lot_finished_1 = self.env['stock.production.lot'].create({ + 'name': 'lot_finished_1', + 'product_id': p_final.id, + 'company_id': self.env.company.id, + }) + + self.assertEqual(mo.product_qty, 5) + mo_form = Form(mo) + mo_form.qty_producing = 3.0 + mo_form.lot_producing_id = lot_finished_1 + mo = mo_form.save() + self.assertEqual(mo.move_raw_ids[1].quantity_done, 12) + details_operation_form = Form(mo.move_raw_ids[0], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.new() as ml: + ml.qty_done = 3 + ml.lot_id = lot_1 + details_operation_form.save() + action = mo.button_mark_done() + backorder = Form(self.env[action['res_model']].with_context(**action['context'])) + backorder.save().action_backorder() + + lot_2 = self.env['stock.production.lot'].create({ + 'name': 'lot_2', + 'product_id': p2.id, + 'company_id': self.env.company.id, + }) + + self.env['stock.quant']._update_available_quantity(p2, self.stock_location, 4, lot_id=lot_2) + lot_finished_2 = self.env['stock.production.lot'].create({ + 'name': 'lot_finished_2', + 'product_id': p_final.id, + 'company_id': self.env.company.id, + }) + + mo = mo.procurement_group_id.mrp_production_ids[1] + # FIXME sle: issue in backorder? + mo.move_raw_ids.move_line_ids.unlink() + self.assertEqual(mo.product_qty, 2) + mo_form = Form(mo) + mo_form.qty_producing = 2 + mo_form.lot_producing_id = lot_finished_2 + mo = mo_form.save() + details_operation_form = Form(mo.move_raw_ids[0], view=self.env.ref('stock.view_stock_move_operations')) + with details_operation_form.move_line_ids.new() as ml: + ml.qty_done = 2 + ml.lot_id = lot_2 + details_operation_form.save() + action = mo.button_mark_done() + + mo1 = mo.procurement_group_id.mrp_production_ids[0] + ml = mo1.finished_move_line_ids[0].consume_line_ids.filtered(lambda m: m.product_id == p1 and lot_finished_1 in m.produce_line_ids.lot_id) + self.assertEqual(sum(ml.mapped('qty_done')), 12.0, 'Should have consumed 12 for the first lot') + ml = mo.finished_move_line_ids[0].consume_line_ids.filtered(lambda m: m.product_id == p1 and lot_finished_2 in m.produce_line_ids.lot_id) + self.assertEqual(sum(ml.mapped('qty_done')), 8.0, 'Should have consumed 8 for the second lot') + + def test_unbuild_with_routes(self): + """ This test creates a MO of a stockable product (Table). A new route for rule QC/Unbuild -> Stock + is created with Warehouse -> True. + The unbuild order should revert the consumed components into QC/Unbuild location for quality check + and then a picking should be generated for transferring components from QC/Unbuild location to stock. + """ + StockQuant = self.env['stock.quant'] + ProductObj = self.env['product.product'] + # Create new QC/Unbuild location + warehouse = self.env.ref('stock.warehouse0') + unbuild_location = self.env['stock.location'].create({ + 'name': 'QC/Unbuild', + 'usage': 'internal', + 'location_id': warehouse.view_location_id.id + }) + + # Create a product route containing a stock rule that will move product from QC/Unbuild location to stock + product_route = self.env['stock.location.route'].create({ + 'name': 'QC/Unbuild -> Stock', + 'warehouse_selectable': True, + 'warehouse_ids': [(4, warehouse.id)], + 'rule_ids': [(0, 0, { + 'name': 'Send Matrial QC/Unbuild -> Stock', + 'action': 'push', + 'picking_type_id': self.ref('stock.picking_type_internal'), + 'location_src_id': unbuild_location.id, + 'location_id': self.stock_location.id, + })], + }) + + # Create a stockable product and its components + finshed_product = ProductObj.create({ + 'name': 'Table', + 'type': 'product', + }) + component1 = ProductObj.create({ + 'name': 'Table head', + 'type': 'product', + }) + component2 = ProductObj.create({ + 'name': 'Table stand', + 'type': 'product', + }) + + # Create bom and add components + bom = self.env['mrp.bom'].create({ + 'product_id': finshed_product.id, + 'product_tmpl_id': finshed_product.product_tmpl_id.id, + 'product_uom_id': self.uom_unit.id, + 'product_qty': 1.0, + 'type': 'normal', + 'bom_line_ids': [ + (0, 0, {'product_id': component1.id, 'product_qty': 1}), + (0, 0, {'product_id': component2.id, 'product_qty': 1}) + ]}) + + # Set on hand quantity + StockQuant._update_available_quantity(component1, self.stock_location, 1) + StockQuant._update_available_quantity(component2, self.stock_location, 1) + + # Create mo + mo_form = Form(self.env['mrp.production']) + mo_form.product_id = finshed_product + mo_form.bom_id = bom + mo_form.product_uom_id = finshed_product.uom_id + mo_form.product_qty = 1.0 + mo = mo_form.save() + self.assertEqual(len(mo), 1, 'MO should have been created') + mo.action_confirm() + mo.action_assign() + + # Produce the final product + mo_form = Form(mo) + mo_form.qty_producing = 1.0 + produce_wizard = mo_form.save() + + mo.button_mark_done() + self.assertEqual(mo.state, 'done', "Production order should be in done state.") + + # Check quantity in stock before unbuild + self.assertEqual(StockQuant._get_available_quantity(finshed_product, self.stock_location), 1, 'Table should be available in stock') + self.assertEqual(StockQuant._get_available_quantity(component1, self.stock_location), 0, 'Table head should not be available in stock') + self.assertEqual(StockQuant._get_available_quantity(component2, self.stock_location), 0, 'Table stand should not be available in stock') + + # --------------------------------------------------- + # Unbuild + # --------------------------------------------------- + + # Create an unbuild order of the finished product and set the destination loacation = QC/Unbuild + x = Form(self.env['mrp.unbuild']) + x.product_id = finshed_product + x.bom_id = bom + x.mo_id = mo + x.product_qty = 1 + x.location_id = self.stock_location + x.location_dest_id = unbuild_location + x.save().action_unbuild() + + # Check the available quantity of components and final product in stock + self.assertEqual(StockQuant._get_available_quantity(finshed_product, self.stock_location), 0, 'Table should not be available in stock as it is unbuild') + self.assertEqual(StockQuant._get_available_quantity(component1, self.stock_location), 0, 'Table head should not be available in stock as it is in QC/Unbuild location') + self.assertEqual(StockQuant._get_available_quantity(component2, self.stock_location), 0, 'Table stand should not be available in stock as it is in QC/Unbuild location') + + # Find new generated picking + picking = self.env['stock.picking'].search([('product_id', 'in', [component1.id, component2.id])]) + self.assertEqual(picking.location_id.id, unbuild_location.id, 'Wrong source location in picking') + self.assertEqual(picking.location_dest_id.id, self.stock_location.id, 'Wrong destination location in picking') + + # Transfer it + for ml in picking.move_ids_without_package: + ml.quantity_done = 1 + picking._action_done() + + # Check the available quantity of components and final product in stock + self.assertEqual(StockQuant._get_available_quantity(finshed_product, self.stock_location), 0, 'Table should not be available in stock') + self.assertEqual(StockQuant._get_available_quantity(component1, self.stock_location), 1, 'Table head should be available in stock as the picking is transferred') + self.assertEqual(StockQuant._get_available_quantity(component2, self.stock_location), 1, 'Table stand should be available in stock as the picking is transferred') diff --git a/addons/mrp/tests/test_warehouse_multistep_manufacturing.py b/addons/mrp/tests/test_warehouse_multistep_manufacturing.py new file mode 100644 index 00000000..1257b5e4 --- /dev/null +++ b/addons/mrp/tests/test_warehouse_multistep_manufacturing.py @@ -0,0 +1,507 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import Form +from odoo.addons.mrp.tests.common import TestMrpCommon + + +class TestMultistepManufacturingWarehouse(TestMrpCommon): + + def setUp(self): + super(TestMultistepManufacturingWarehouse, self).setUp() + # Create warehouse + self.customer_location = self.env['ir.model.data'].xmlid_to_res_id('stock.stock_location_customers') + warehouse_form = Form(self.env['stock.warehouse']) + warehouse_form.name = 'Test Warehouse' + warehouse_form.code = 'TWH' + self.warehouse = warehouse_form.save() + + self.uom_unit = self.env.ref('uom.product_uom_unit') + + # Create manufactured product + product_form = Form(self.env['product.product']) + product_form.name = 'Stick' + product_form.uom_id = self.uom_unit + product_form.uom_po_id = self.uom_unit + product_form.type = 'product' + product_form.route_ids.clear() + product_form.route_ids.add(self.warehouse.manufacture_pull_id.route_id) + product_form.route_ids.add(self.warehouse.mto_pull_id.route_id) + self.finished_product = product_form.save() + + # Create raw product for manufactured product + product_form = Form(self.env['product.product']) + product_form.name = 'Raw Stick' + product_form.type = 'product' + product_form.uom_id = self.uom_unit + product_form.uom_po_id = self.uom_unit + self.raw_product = product_form.save() + + # Create bom for manufactured product + bom_product_form = Form(self.env['mrp.bom']) + bom_product_form.product_id = self.finished_product + bom_product_form.product_tmpl_id = self.finished_product.product_tmpl_id + bom_product_form.product_qty = 1.0 + bom_product_form.type = 'normal' + with bom_product_form.bom_line_ids.new() as bom_line: + bom_line.product_id = self.raw_product + bom_line.product_qty = 2.0 + + self.bom = bom_product_form.save() + + def _check_location_and_routes(self): + # Check manufacturing pull rule. + self.assertTrue(self.warehouse.manufacture_pull_id) + self.assertTrue(self.warehouse.manufacture_pull_id.active, self.warehouse.manufacture_to_resupply) + self.assertTrue(self.warehouse.manufacture_pull_id.route_id) + # Check new routes created or not. + self.assertTrue(self.warehouse.pbm_route_id) + # Check location should be created and linked to warehouse. + self.assertTrue(self.warehouse.pbm_loc_id) + self.assertEqual(self.warehouse.pbm_loc_id.active, self.warehouse.manufacture_steps != 'mrp_one_step', "Input location must be de-active for single step only.") + self.assertTrue(self.warehouse.manu_type_id.active) + + def test_00_create_warehouse(self): + """ Warehouse testing for direct manufacturing """ + with Form(self.warehouse) as warehouse: + warehouse.manufacture_steps = 'mrp_one_step' + self._check_location_and_routes() + # Check locations of existing pull rule + self.assertFalse(self.warehouse.pbm_route_id.rule_ids, 'only the update of global manufacture route should happen.') + self.assertEqual(self.warehouse.manufacture_pull_id.location_id.id, self.warehouse.lot_stock_id.id) + + def test_01_warehouse_twostep_manufacturing(self): + """ Warehouse testing for picking before manufacturing """ + with Form(self.warehouse) as warehouse: + warehouse.manufacture_steps = 'pbm' + self._check_location_and_routes() + self.assertEqual(len(self.warehouse.pbm_route_id.rule_ids), 2) + self.assertEqual(self.warehouse.manufacture_pull_id.location_id.id, self.warehouse.lot_stock_id.id) + + def test_02_warehouse_twostep_manufacturing(self): + """ Warehouse testing for picking ans store after manufacturing """ + with Form(self.warehouse) as warehouse: + warehouse.manufacture_steps = 'pbm_sam' + self._check_location_and_routes() + self.assertEqual(len(self.warehouse.pbm_route_id.rule_ids), 3) + self.assertEqual(self.warehouse.manufacture_pull_id.location_id.id, self.warehouse.sam_loc_id.id) + + def test_manufacturing_3_steps(self): + """ Test MO/picking before manufacturing/picking after manufacturing + components and move_orig/move_dest. Ensure that everything is created + correctly. + """ + with Form(self.warehouse) as warehouse: + warehouse.manufacture_steps = 'pbm_sam' + + production_form = Form(self.env['mrp.production']) + production_form.product_id = self.finished_product + production_form.picking_type_id = self.warehouse.manu_type_id + production = production_form.save() + production.action_confirm() + + move_raw_ids = production.move_raw_ids + self.assertEqual(len(move_raw_ids), 1) + self.assertEqual(move_raw_ids.product_id, self.raw_product) + self.assertEqual(move_raw_ids.picking_type_id, self.warehouse.manu_type_id) + pbm_move = move_raw_ids.move_orig_ids + self.assertEqual(len(pbm_move), 1) + self.assertEqual(pbm_move.location_id, self.warehouse.lot_stock_id) + self.assertEqual(pbm_move.location_dest_id, self.warehouse.pbm_loc_id) + self.assertEqual(pbm_move.picking_type_id, self.warehouse.pbm_type_id) + self.assertFalse(pbm_move.move_orig_ids) + + move_finished_ids = production.move_finished_ids + self.assertEqual(len(move_finished_ids), 1) + self.assertEqual(move_finished_ids.product_id, self.finished_product) + self.assertEqual(move_finished_ids.picking_type_id, self.warehouse.manu_type_id) + sam_move = move_finished_ids.move_dest_ids + self.assertEqual(len(sam_move), 1) + self.assertEqual(sam_move.location_id, self.warehouse.sam_loc_id) + self.assertEqual(sam_move.location_dest_id, self.warehouse.lot_stock_id) + self.assertEqual(sam_move.picking_type_id, self.warehouse.sam_type_id) + self.assertFalse(sam_move.move_dest_ids) + + def test_manufacturing_flow(self): + """ Simulate a pick pack ship delivery combined with a picking before + manufacturing and store after manufacturing. Also ensure that the MO and + the moves to stock are created with the generic pull rules. + In order to trigger the rule we create a picking to the customer with + the 'make to order' procure method + """ + with Form(self.warehouse) as warehouse: + warehouse.manufacture_steps = 'pbm_sam' + warehouse.delivery_steps = 'pick_pack_ship' + self.warehouse.flush() + self.env.ref('stock.route_warehouse0_mto').active = True + self.env['stock.quant']._update_available_quantity(self.raw_product, self.warehouse.lot_stock_id, 4.0) + picking_customer = self.env['stock.picking'].create({ + 'location_id': self.warehouse.wh_output_stock_loc_id.id, + 'location_dest_id': self.customer_location, + 'partner_id': self.env['ir.model.data'].xmlid_to_res_id('base.res_partner_4'), + 'picking_type_id': self.warehouse.out_type_id.id, + }) + self.env['stock.move'].create({ + 'name': self.finished_product.name, + 'product_id': self.finished_product.id, + 'product_uom_qty': 2, + 'product_uom': self.uom_unit.id, + 'picking_id': picking_customer.id, + 'location_id': self.warehouse.wh_output_stock_loc_id.id, + 'location_dest_id': self.customer_location, + 'procure_method': 'make_to_order', + 'origin': 'SOURCEDOCUMENT', + 'state': 'draft', + }) + picking_customer.action_confirm() + production_order = self.env['mrp.production'].search([('product_id', '=', self.finished_product.id)]) + self.assertTrue(production_order) + self.assertEqual(production_order.origin, 'SOURCEDOCUMENT', 'The MO origin should be the SO name') + self.assertNotEqual(production_order.name, 'SOURCEDOCUMENT', 'The MO name should not be the origin of the move') + + picking_stock_preprod = self.env['stock.move'].search([ + ('product_id', '=', self.raw_product.id), + ('location_id', '=', self.warehouse.lot_stock_id.id), + ('location_dest_id', '=', self.warehouse.pbm_loc_id.id), + ('picking_type_id', '=', self.warehouse.pbm_type_id.id) + ]).picking_id + picking_stock_postprod = self.env['stock.move'].search([ + ('product_id', '=', self.finished_product.id), + ('location_id', '=', self.warehouse.sam_loc_id.id), + ('location_dest_id', '=', self.warehouse.lot_stock_id.id), + ('picking_type_id', '=', self.warehouse.sam_type_id.id) + ]).picking_id + + self.assertTrue(picking_stock_preprod) + self.assertTrue(picking_stock_postprod) + self.assertEqual(picking_stock_preprod.state, 'confirmed') + self.assertEqual(picking_stock_postprod.state, 'waiting') + self.assertEqual(picking_stock_preprod.origin, production_order.name, 'The pre-prod origin should be the MO name') + self.assertEqual(picking_stock_postprod.origin, 'SOURCEDOCUMENT', 'The post-prod origin should be the SO name') + + picking_stock_preprod.action_assign() + picking_stock_preprod.move_line_ids.qty_done = 4 + picking_stock_preprod._action_done() + + self.assertFalse(sum(self.env['stock.quant']._gather(self.raw_product, self.warehouse.lot_stock_id).mapped('quantity'))) + self.assertTrue(self.env['stock.quant']._gather(self.raw_product, self.warehouse.pbm_loc_id)) + + production_order.action_assign() + self.assertEqual(production_order.reservation_state, 'assigned') + self.assertEqual(picking_stock_postprod.state, 'waiting') + + produce_form = Form(production_order) + produce_form.qty_producing = production_order.product_qty + production_order = produce_form.save() + production_order.button_mark_done() + + self.assertFalse(sum(self.env['stock.quant']._gather(self.raw_product, self.warehouse.pbm_loc_id).mapped('quantity'))) + + self.assertEqual(picking_stock_postprod.state, 'assigned') + + picking_stock_pick = self.env['stock.move'].search([ + ('product_id', '=', self.finished_product.id), + ('location_id', '=', self.warehouse.lot_stock_id.id), + ('location_dest_id', '=', self.warehouse.wh_pack_stock_loc_id.id), + ('picking_type_id', '=', self.warehouse.pick_type_id.id) + ]).picking_id + self.assertEqual(picking_stock_pick.move_lines.move_orig_ids.picking_id, picking_stock_postprod) + + def test_cancel_propagation(self): + """ Test cancelling moves in a 'picking before + manufacturing' and 'store after manufacturing' process. The propagation of + cancel depends on the default values on each rule of the chain. + """ + self.warehouse.manufacture_steps = 'pbm_sam' + self.warehouse.flush() + self.env['stock.quant']._update_available_quantity(self.raw_product, self.warehouse.lot_stock_id, 4.0) + picking_customer = self.env['stock.picking'].create({ + 'location_id': self.warehouse.lot_stock_id.id, + 'location_dest_id': self.customer_location, + 'partner_id': self.env['ir.model.data'].xmlid_to_res_id('base.res_partner_4'), + 'picking_type_id': self.warehouse.out_type_id.id, + }) + self.env['stock.move'].create({ + 'name': self.finished_product.name, + 'product_id': self.finished_product.id, + 'product_uom_qty': 2, + 'picking_id': picking_customer.id, + 'product_uom': self.uom_unit.id, + 'location_id': self.warehouse.lot_stock_id.id, + 'location_dest_id': self.customer_location, + 'procure_method': 'make_to_order', + }) + picking_customer.action_confirm() + production_order = self.env['mrp.production'].search([('product_id', '=', self.finished_product.id)]) + self.assertTrue(production_order) + + move_stock_preprod = self.env['stock.move'].search([ + ('product_id', '=', self.raw_product.id), + ('location_id', '=', self.warehouse.lot_stock_id.id), + ('location_dest_id', '=', self.warehouse.pbm_loc_id.id), + ('picking_type_id', '=', self.warehouse.pbm_type_id.id) + ]) + move_stock_postprod = self.env['stock.move'].search([ + ('product_id', '=', self.finished_product.id), + ('location_id', '=', self.warehouse.sam_loc_id.id), + ('location_dest_id', '=', self.warehouse.lot_stock_id.id), + ('picking_type_id', '=', self.warehouse.sam_type_id.id) + ]) + + self.assertTrue(move_stock_preprod) + self.assertTrue(move_stock_postprod) + self.assertEqual(move_stock_preprod.state, 'confirmed') + self.assertEqual(move_stock_postprod.state, 'waiting') + + move_stock_preprod._action_cancel() + self.assertEqual(production_order.state, 'confirmed') + production_order.action_cancel() + self.assertTrue(move_stock_postprod.state, 'cancel') + + def test_no_initial_demand(self): + """ Test MO/picking before manufacturing/picking after manufacturing + components and move_orig/move_dest. Ensure that everything is created + correctly. + """ + with Form(self.warehouse) as warehouse: + warehouse.manufacture_steps = 'pbm_sam' + production_form = Form(self.env['mrp.production']) + production_form.product_id = self.finished_product + production_form.picking_type_id = self.warehouse.manu_type_id + production = production_form.save() + production.move_raw_ids.product_uom_qty = 0 + production.action_confirm() + production.action_assign() + self.assertFalse(production.move_raw_ids.move_orig_ids) + self.assertEqual(production.state, 'confirmed') + self.assertEqual(production.reservation_state, 'assigned') + + def test_manufacturing_3_steps_flexible(self): + """ Test MO/picking before manufacturing/picking after manufacturing + components and move_orig/move_dest. Ensure that additional moves are put + in picking before manufacturing too. + """ + with Form(self.warehouse) as warehouse: + warehouse.manufacture_steps = 'pbm_sam' + bom = self.env['mrp.bom'].search([ + ('product_id', '=', self.finished_product.id) + ]) + new_product = self.env['product.product'].create({ + 'name': 'New product', + 'type': 'product', + }) + bom.consumption = 'flexible' + production_form = Form(self.env['mrp.production']) + production_form.product_id = self.finished_product + production_form.picking_type_id = self.warehouse.manu_type_id + production = production_form.save() + + production.action_confirm() + + production_form = Form(production) + with production_form.move_raw_ids.new() as move: + move.product_id = new_product + move.product_uom_qty = 2 + production = production_form.save() + move_raw_ids = production.move_raw_ids + self.assertEqual(len(move_raw_ids), 2) + pbm_move = move_raw_ids.move_orig_ids + self.assertEqual(len(pbm_move), 2) + self.assertTrue(new_product in pbm_move.product_id) + + def test_manufacturing_complex_product_3_steps(self): + """ Test MO/picking after manufacturing a complex product which uses + manufactured components. Ensure that everything is created and picked + correctly. + """ + + self.warehouse.mto_pull_id.route_id.active = True + # Creating complex product which trigger another manifacture + + product_form = Form(self.env['product.product']) + product_form.name = 'Arrow' + product_form.type = 'product' + product_form.route_ids.clear() + product_form.route_ids.add(self.warehouse.manufacture_pull_id.route_id) + product_form.route_ids.add(self.warehouse.mto_pull_id.route_id) + self.complex_product = product_form.save() + + ## Create raw product for manufactured product + product_form = Form(self.env['product.product']) + product_form.name = 'Raw Iron' + product_form.type = 'product' + product_form.uom_id = self.uom_unit + product_form.uom_po_id = self.uom_unit + self.raw_product_2 = product_form.save() + + with Form(self.finished_product) as finished_product: + finished_product.route_ids.clear() + finished_product.route_ids.add(self.warehouse.manufacture_pull_id.route_id) + finished_product.route_ids.add(self.warehouse.mto_pull_id.route_id) + + ## Create bom for manufactured product + bom_product_form = Form(self.env['mrp.bom']) + bom_product_form.product_id = self.complex_product + bom_product_form.product_tmpl_id = self.complex_product.product_tmpl_id + with bom_product_form.bom_line_ids.new() as line: + line.product_id = self.finished_product + line.product_qty = 1.0 + with bom_product_form.bom_line_ids.new() as line: + line.product_id = self.raw_product_2 + line.product_qty = 1.0 + + self.complex_bom = bom_product_form.save() + + with Form(self.warehouse) as warehouse: + warehouse.manufacture_steps = 'pbm_sam' + + production_form = Form(self.env['mrp.production']) + production_form.product_id = self.complex_product + production_form.picking_type_id = self.warehouse.manu_type_id + production = production_form.save() + production.action_confirm() + + move_raw_ids = production.move_raw_ids + self.assertEqual(len(move_raw_ids), 2) + sfp_move_raw_id, raw_move_raw_id = move_raw_ids + self.assertEqual(sfp_move_raw_id.product_id, self.finished_product) + self.assertEqual(raw_move_raw_id.product_id, self.raw_product_2) + + for move_raw_id in move_raw_ids: + self.assertEqual(move_raw_id.picking_type_id, self.warehouse.manu_type_id) + + pbm_move = move_raw_id.move_orig_ids + self.assertEqual(len(pbm_move), 1) + self.assertEqual(pbm_move.location_id, self.warehouse.lot_stock_id) + self.assertEqual(pbm_move.location_dest_id, self.warehouse.pbm_loc_id) + self.assertEqual(pbm_move.picking_type_id, self.warehouse.pbm_type_id) + + # Check move locations + move_finished_ids = production.move_finished_ids + self.assertEqual(len(move_finished_ids), 1) + self.assertEqual(move_finished_ids.product_id, self.complex_product) + self.assertEqual(move_finished_ids.picking_type_id, self.warehouse.manu_type_id) + sam_move = move_finished_ids.move_dest_ids + self.assertEqual(len(sam_move), 1) + self.assertEqual(sam_move.location_id, self.warehouse.sam_loc_id) + self.assertEqual(sam_move.location_dest_id, self.warehouse.lot_stock_id) + self.assertEqual(sam_move.picking_type_id, self.warehouse.sam_type_id) + self.assertFalse(sam_move.move_dest_ids) + + subproduction = self.env['mrp.production'].browse(production.id+1) + sfp_pickings = subproduction.picking_ids.sorted('id') + + # SFP Production: 2 pickings, 1 group + self.assertEqual(len(sfp_pickings), 2) + self.assertEqual(sfp_pickings.mapped('group_id'), subproduction.procurement_group_id) + + ## Move Raw Stick - Stock -> Preprocessing + picking = sfp_pickings[0] + self.assertEqual(len(picking.move_lines), 1) + picking.move_lines[0].product_id = self.raw_product + + ## Move SFP - PostProcessing -> Stock + picking = sfp_pickings[1] + self.assertEqual(len(picking.move_lines), 1) + picking.move_lines[0].product_id = self.finished_product + + # Main production 2 pickings, 1 group + pickings = production.picking_ids.sorted('id') + self.assertEqual(len(pickings), 2) + self.assertEqual(pickings.mapped('group_id'), production.procurement_group_id) + + ## Move 2 components Stock -> Preprocessing + picking = pickings[0] + self.assertEqual(len(picking.move_lines), 2) + picking.move_lines[0].product_id = self.finished_product + picking.move_lines[1].product_id = self.raw_product_2 + + ## Move FP PostProcessing -> Stock + picking = pickings[1] + self.assertEqual(len(picking.move_lines), 1) + picking.product_id = self.complex_product + + def test_3_steps_and_byproduct(self): + """ Suppose a warehouse with Manufacture option set to '3 setps' and a product P01 with a reordering rule. + Suppose P01 has a BoM and this BoM mentions that when some P01 are produced, some P02 are produced too. + This test ensures that when a MO is generated thanks to the reordering rule, 2 pickings are also + generated: + - One to bring the components + - Another to return the P01 and P02 produced + """ + warehouse = self.warehouse + warehouse.manufacture_steps = 'pbm_sam' + warehouse_stock_location = warehouse.lot_stock_id + pre_production_location = warehouse.pbm_loc_id + post_production_location = warehouse.sam_loc_id + + one_unit_uom = self.env['ir.model.data'].xmlid_to_object('uom.product_uom_unit') + [two_units_uom, four_units_uom] = self.env['uom.uom'].create([{ + 'name': 'x%s' % i, + 'category_id': self.ref('uom.product_uom_categ_unit'), + 'uom_type': 'bigger', + 'factor_inv': i, + } for i in [2, 4]]) + + finished_product = self.env['product.product'].create({ + 'name': 'Super Product', + 'route_ids': [(4, self.ref('mrp.route_warehouse0_manufacture'))], + 'type': 'product', + }) + secondary_product = self.env['product.product'].create({ + 'name': 'Secondary', + 'type': 'product', + }) + component = self.env['product.product'].create({ + 'name': 'Component', + 'type': 'consu', + }) + + bom = self.env['mrp.bom'].create({ + 'product_tmpl_id': finished_product.product_tmpl_id.id, + 'product_qty': 1, + 'product_uom_id': two_units_uom.id, + 'bom_line_ids': [(0, 0, { + 'product_id': component.id, + 'product_qty': 1, + 'product_uom_id': one_unit_uom.id, + })], + 'byproduct_ids': [(0, 0, { + 'product_id': secondary_product.id, + 'product_qty': 1, + 'product_uom_id': four_units_uom.id, + })], + }) + + orderpoint = self.env['stock.warehouse.orderpoint'].create({ + 'warehouse_id': warehouse.id, + 'location_id': warehouse_stock_location.id, + 'product_id': finished_product.id, + 'product_min_qty': 2, + 'product_max_qty': 2, + }) + + self.env['procurement.group'].run_scheduler() + mo = self.env['mrp.production'].search([('product_id', '=', finished_product.id)]) + pickings = mo.picking_ids + self.assertEqual(len(pickings), 2) + + preprod_picking = pickings[0] if pickings[0].location_id == warehouse_stock_location else pickings[1] + self.assertEqual(preprod_picking.location_id, warehouse_stock_location) + self.assertEqual(preprod_picking.location_dest_id, pre_production_location) + + postprod_picking = pickings - preprod_picking + self.assertEqual(postprod_picking.location_id, post_production_location) + self.assertEqual(postprod_picking.location_dest_id, warehouse_stock_location) + + postprod_SML = postprod_picking.move_lines + self.assertEqual(len(postprod_SML), 2) + self.assertEqual(postprod_SML.location_id, post_production_location) + self.assertEqual(postprod_SML.location_dest_id, warehouse_stock_location) + + finished_product_SML = postprod_SML[0] if postprod_SML[0].product_id == finished_product else postprod_SML[1] + secondary_product_SML = postprod_SML - finished_product_SML + self.assertEqual(finished_product_SML.product_uom.id, one_unit_uom.id) + self.assertEqual(finished_product_SML.product_uom_qty, 2) + self.assertEqual(secondary_product_SML.product_uom.id, four_units_uom.id) + self.assertEqual(secondary_product_SML.product_uom_qty, 1) |
