diff options
| author | Arnaud Baes <arb@odoo.com> | 2019-01-16 12:25:31 +0000 |
|---|---|---|
| committer | Arnaud Baes <arb@odoo.com> | 2019-01-25 09:00:55 +0000 |
| commit | 8298142574516de2632ac0e87b8f36f1fddd7c2c (patch) | |
| tree | 8119e65feab81ee82d6473ed343ac30fb6645461 /addons/purchase_mrp | |
| parent | fae4de5c348c59af2f16c186e542eeb39abb3e2a (diff) | |
[IMP] mrp, purchase_mrp: Quantity received for kits in a PO
This commit generalize the method made for computing the quantity delivered in a sale order
in order to be used for the quantity received for a kit in a purchase order.
TaskID: 1929518
Diffstat (limited to 'addons/purchase_mrp')
| -rw-r--r-- | addons/purchase_mrp/__manifest__.py | 2 | ||||
| -rw-r--r-- | addons/purchase_mrp/models/purchase_mrp.py | 27 | ||||
| -rw-r--r-- | addons/purchase_mrp/tests/__init__.py | 4 | ||||
| -rw-r--r-- | addons/purchase_mrp/tests/test_purchase_mrp_flow.py | 416 |
4 files changed, 431 insertions, 18 deletions
diff --git a/addons/purchase_mrp/__manifest__.py b/addons/purchase_mrp/__manifest__.py index 4332ed219a7..26842162b95 100644 --- a/addons/purchase_mrp/__manifest__.py +++ b/addons/purchase_mrp/__manifest__.py @@ -13,7 +13,7 @@ This module provides facility to the user to install mrp and purchase modules at It is basically used when we want to keep track of production orders generated from purchase order. """, - 'depends': ['mrp', 'purchase'], + 'depends': ['mrp', 'purchase_stock'], 'installable': True, 'auto_install': True, } diff --git a/addons/purchase_mrp/models/purchase_mrp.py b/addons/purchase_mrp/models/purchase_mrp.py index 7c0e78381a8..00fafc6dbdd 100644 --- a/addons/purchase_mrp/models/purchase_mrp.py +++ b/addons/purchase_mrp/models/purchase_mrp.py @@ -18,23 +18,16 @@ class PurchaseOrderLine(models.Model): def _compute_qty_received(self): super(PurchaseOrderLine, self)._compute_qty_received() for line in self: - if line.qty_received_method == 'stock_moves' and line.move_ids and line.product_id.id not in line.move_ids.mapped('product_id').ids: - bom = self.env['mrp.bom']._bom_find(product=line.product_id, company_id=line.company_id.id, bom_type='phantom') - if bom: - line.qty_received = line._get_bom_delivered(bom=bom) - - def _get_bom_delivered(self, bom=False): - self.ensure_one() - - # In the case of a kit, we need to check if all components are shipped. Since the BOM might - # have changed, we don't compute the quantities but verify the move state. - if bom: - moves = self.move_ids.filtered(lambda m: m.picking_id and m.picking_id.state != 'cancel') - bom_delivered = all([move.state == 'done' for move in moves]) - if bom_delivered: - return self.product_qty - else: - return 0.0 + if line.qty_received_method == 'stock_moves' and line.move_ids: + kit_bom = self.env['mrp.bom']._bom_find(product=line.product_id, company_id=line.company_id.id, bom_type='phantom') + if kit_bom: + moves = line.move_ids.filtered(lambda m: m.state == 'done' and not m.scrapped) + order_qty = line.product_uom._compute_quantity(line.product_uom_qty, kit_bom.product_uom_id) + filters = { + 'incoming_moves': lambda m: m.location_id.usage == 'supplier' and (not m.origin_returned_move_id or (m.origin_returned_move_id and m.to_refund)), + 'outgoing_moves': lambda m: m.location_id.usage != 'supplier' and m.to_refund + } + line.qty_received = moves._compute_kit_quantities(line.product_id, order_qty, kit_bom, filters) def _get_upstream_documents_and_responsibles(self, visited): return [(self.order_id, self.order_id.user_id, visited)] diff --git a/addons/purchase_mrp/tests/__init__.py b/addons/purchase_mrp/tests/__init__.py new file mode 100644 index 00000000000..45619da7f61 --- /dev/null +++ b/addons/purchase_mrp/tests/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import test_purchase_mrp_flow diff --git a/addons/purchase_mrp/tests/test_purchase_mrp_flow.py b/addons/purchase_mrp/tests/test_purchase_mrp_flow.py new file mode 100644 index 00000000000..617f84b73c2 --- /dev/null +++ b/addons/purchase_mrp/tests/test_purchase_mrp_flow.py @@ -0,0 +1,416 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import common, Form + + +class TestSaleMrpFlow(common.TransactionCase): + + def setUp(self): + super(TestSaleMrpFlow, self).setUp() + # Useful models + self.UoM = self.env['uom.uom'] + self.categ_unit = self.env.ref('uom.product_uom_categ_unit') + self.categ_kgm = self.env.ref('uom.product_uom_categ_kgm') + self.stock_location = self.env.ref('stock.stock_location_stock') + self.warehouse = self.env.ref('stock.warehouse0') + + self.uom_kg = self.env['uom.uom'].search([('category_id', '=', self.categ_kgm.id), ('uom_type', '=', 'reference')], + limit=1) + self.uom_kg.write({ + 'name': 'Test-KG', + 'rounding': 0.000001}) + self.uom_gm = self.UoM.create({ + 'name': 'Test-G', + 'category_id': self.categ_kgm.id, + 'uom_type': 'smaller', + 'factor': 1000.0, + 'rounding': 0.001}) + self.uom_unit = self.env['uom.uom'].search( + [('category_id', '=', self.categ_unit.id), ('uom_type', '=', 'reference')], limit=1) + self.uom_unit.write({ + 'name': 'Test-Unit', + 'rounding': 0.01}) + self.uom_dozen = self.UoM.create({ + 'name': 'Test-DozenA', + 'category_id': self.categ_unit.id, + 'factor_inv': 12, + 'uom_type': 'bigger', + 'rounding': 0.001}) + + # Creating all components + self.component_a = self._create_product('Comp A', self.uom_unit) + self.component_b = self._create_product('Comp B', self.uom_unit) + self.component_c = self._create_product('Comp C', self.uom_unit) + self.component_d = self._create_product('Comp D', self.uom_unit) + self.component_e = self._create_product('Comp E', self.uom_unit) + self.component_f = self._create_product('Comp F', self.uom_unit) + self.component_g = self._create_product('Comp G', self.uom_unit) + + # Create a kit 'kit_1' : + # ----------------------- + # + # kit_1 --|- component_a x2 + # |- component_b x1 + # |- component_c x3 + + self.kit_1 = self._create_product('Kit 1', self.uom_unit) + + self.bom_kit_1 = self.env['mrp.bom'].create({ + 'product_tmpl_id': self.kit_1.product_tmpl_id.id, + 'product_qty': 1.0, + 'type': 'phantom'}) + + BomLine = self.env['mrp.bom.line'] + BomLine.create({ + 'product_id': self.component_a.id, + 'product_qty': 2.0, + 'bom_id': self.bom_kit_1.id}) + BomLine.create({ + 'product_id': self.component_b.id, + 'product_qty': 1.0, + 'bom_id': self.bom_kit_1.id}) + BomLine.create({ + 'product_id': self.component_c.id, + 'product_qty': 3.0, + 'bom_id': self.bom_kit_1.id}) + + # 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 kits + self.kit_2 = self._create_product('Kit 2', self.uom_unit) + self.kit_3 = self._create_product('kit 3', self.uom_unit) + self.kit_parent = self._create_product('Kit Parent', self.uom_unit) + + # Linking the kits and the components via some 'phantom' BoMs + bom_kit_2 = self.env['mrp.bom'].create({ + 'product_tmpl_id': self.kit_2.product_tmpl_id.id, + 'product_qty': 1.0, + 'type': 'phantom'}) + + BomLine.create({ + 'product_id': self.component_d.id, + 'product_qty': 1.0, + 'bom_id': bom_kit_2.id}) + BomLine.create({ + 'product_id': self.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': self.component_e.id, + 'product_qty': 1.0, + 'bom_id': bom_kit_parent.id}) + BomLine.create({ + 'product_id': self.kit_2.id, + 'product_qty': 2.0, + 'bom_id': bom_kit_parent.id}) + + bom_kit_3 = self.env['mrp.bom'].create({ + 'product_tmpl_id': self.kit_3.product_tmpl_id.id, + 'product_qty': 1.0, + 'type': 'phantom'}) + + BomLine.create({ + 'product_id': self.component_f.id, + 'product_qty': 1.0, + 'bom_id': bom_kit_3.id}) + BomLine.create({ + 'product_id': self.component_g.id, + 'product_qty': 2.0, + 'bom_id': bom_kit_3.id}) + + BomLine.create({ + 'product_id': self.kit_3.id, + 'product_qty': 2.0, + 'bom_id': bom_kit_parent.id}) + + def _create_product(self, name, uom_id, routes=()): + p = Form(self.env['product.product']) + p.name = name + p.type = 'product' + p.uom_id = uom_id + p.uom_po_id = uom_id + p.route_ids.clear() + for r in routes: + p.route_ids.add(r) + return p.save() + + # Helper to process quantities based on a dict following this structure : + # + # qty_to_process = { + # product_id: qty + # } + + def _process_quantities(self, moves, quantities_to_process): + """ Helper to process quantities based on a dict following this structure : + qty_to_process = { + product_id: qty + } + """ + moves_to_process = moves.filtered(lambda m: m.product_id in quantities_to_process.keys()) + for move in moves_to_process: + move.write({'quantity_done': quantities_to_process[move.product_id]}) + + def _assert_quantities(self, moves, quantities_to_process): + """ Helper to check expected quantities based on a dict following this structure : + qty_to_process = { + product_id: qty + ... + } + """ + moves_to_process = moves.filtered(lambda m: m.product_id in quantities_to_process.keys()) + for move in moves_to_process: + self.assertEquals(move.product_uom_qty, quantities_to_process[move.product_id]) + + def _create_move_quantities(self, qty_to_process, components, warehouse): + """ Helper to creates moves in order to update the quantities of components + on a specific warehouse. This ensure that all compute fields are triggered. + The structure of qty_to_process should be the following : + + qty_to_process = { + component: (qty, uom), + ... + } + """ + for comp in components: + f = Form(self.env['stock.move']) + f.name = 'Test Receipt Components' + f.location_id = self.env.ref('stock.stock_location_suppliers') + f.location_dest_id = warehouse.lot_stock_id + f.product_id = comp + f.product_uom = qty_to_process[comp][1] + f.product_uom_qty = qty_to_process[comp][0] + move = f.save() + move._action_confirm() + move._action_assign() + move_line = move.move_line_ids[0] + move_line.qty_done = qty_to_process[comp][0] + move._action_done() + + def test_01_sale_mrp_kit_qty_delivered(self): + """ Test that the quantities delivered are correct when + a kit with subkits is ordered with multiple backorders and returns + """ + + # 'kit_parent' structure: + # --------------------------- + # + # 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 + + # Creation of a sale order for x7 kit_parent + partner = self.env.ref('base.res_partner_1') + f = Form(self.env['purchase.order']) + f.partner_id = partner + with f.order_line.new() as line: + line.product_id = self.kit_parent + line.product_qty = 7.0 + line.price_unit = 10 + + po = f.save() + po.button_confirm() + + # Check picking creation, its move lines should concern + # only components. Also checks that the quantities are corresponding + # to the PO + self.assertEquals(len(po.picking_ids), 1) + order_line = po.order_line[0] + picking_original = po.picking_ids[0] + move_lines = picking_original.move_lines + products = move_lines.mapped('product_id') + kits = [self.kit_parent, self.kit_3, self.kit_2, self.kit_1] + components = [self.component_a, self.component_b, self.component_c, self.component_d, self.component_e, + self.component_f, self.component_g] + expected_quantities = { + self.component_a: 56.0, + self.component_b: 28.0, + self.component_c: 84.0, + self.component_d: 14.0, + self.component_e: 7.0, + self.component_f: 14.0, + self.component_g: 28.0 + } + + self.assertEquals(len(move_lines), 7) + self.assertTrue(not any(kit in products for kit in kits)) + self.assertTrue(all(component in products for component in components)) + self._assert_quantities(move_lines, expected_quantities) + + # Process only 7 units of each component + qty_to_process = 7 + move_lines.write({'quantity_done': qty_to_process}) + + # Create a backorder for the missing componenents + backorder_wizard = self.env['stock.backorder.confirmation'].create({'pick_ids': [(4, po.picking_ids[0].id)]}) + backorder_wizard.process() + + # Check that a backorded is created + self.assertEquals(len(po.picking_ids), 2) + backorder_1 = po.picking_ids - picking_original + self.assertEquals(backorder_1.backorder_id.id, picking_original.id) + + # Even if some components are received completely, + # no KitParent should be received + self.assertEquals(order_line.qty_received, 0) + + # Process just enough components to make 1 kit_parent + qty_to_process = { + self.component_a: 1, + self.component_c: 5, + } + self._process_quantities(backorder_1.move_lines, qty_to_process) + + # Create a backorder for the missing componenents + backorder_wizard = self.env['stock.backorder.confirmation'].create({'pick_ids': [(4, backorder_1.id)]}) + backorder_wizard.process() + + # Only 1 kit_parent should be received at this point + self.assertEquals(order_line.qty_received, 1) + + # Check that the second backorder is created + self.assertEquals(len(po.picking_ids), 3) + backorder_2 = po.picking_ids - picking_original - backorder_1 + self.assertEquals(backorder_2.backorder_id.id, backorder_1.id) + + # Set the components quantities that backorder_2 should have + expected_quantities = { + self.component_a: 48, + self.component_b: 21, + self.component_c: 72, + self.component_d: 7, + self.component_f: 7, + self.component_g: 21 + } + + # Check that the computed quantities are matching the theorical ones. + # Since component_e was totally processed, this componenent shouldn't be + # present in backorder_2 + self.assertEquals(len(backorder_2.move_lines), 6) + move_comp_e = backorder_2.move_lines.filtered(lambda m: m.product_id.id == self.component_e.id) + self.assertFalse(move_comp_e) + self._assert_quantities(backorder_2.move_lines, expected_quantities) + + # Process enough components to make x3 kit_parents + qty_to_process = { + self.component_a: 16, + self.component_b: 5, + self.component_c: 24, + self.component_g: 5 + } + self._process_quantities(backorder_2.move_lines, qty_to_process) + + # Create a backorder for the missing componenents + backorder_wizard = self.env['stock.backorder.confirmation'].create({'pick_ids': [(4, backorder_2.id)]}) + backorder_wizard.process() + + # Check that x3 kit_parents are indeed received + self.assertEquals(order_line.qty_received, 3) + + # Check that the third backorder is created + self.assertEquals(len(po.picking_ids), 4) + backorder_3 = po.picking_ids - (picking_original + backorder_1 + backorder_2) + self.assertEquals(backorder_3.backorder_id.id, backorder_2.id) + + # Check the components quantities that backorder_3 should have + expected_quantities = { + self.component_a: 32, + self.component_b: 16, + self.component_c: 48, + self.component_d: 7, + self.component_f: 7, + self.component_g: 16 + } + self._assert_quantities(backorder_3.move_lines, expected_quantities) + + # Process all missing components + self._process_quantities(backorder_3.move_lines, expected_quantities) + + # Validating the last backorder now it's complete. + # All kits should be received + backorder_3.button_validate() + self.assertEquals(order_line.qty_received, 7.0) + + # Return all components processed by backorder_3 + default_data = self.env['stock.return.picking'].with_context( + active_ids=backorder_3.ids, + active_id=backorder_3.ids[0] + ).default_get(['move_dest_exists', 'original_location_id', 'product_return_moves', 'parent_location_id', 'location_id']) + return_wiz = self.env['stock.return.picking'].with_context( + active_ids=backorder_3.ids, + active_id=backorder_3.ids[0] + ).create(default_data) + for return_move in return_wiz.product_return_moves: + return_move.write({ + 'quantity': expected_quantities[return_move.product_id], + 'to_refund': True + }) + res = return_wiz.create_returns() + return_pick = self.env['stock.picking'].browse(res['res_id']) + + # Process all components and validate the picking + wiz_act = return_pick.button_validate() + wiz = self.env[wiz_act['res_model']].browse(wiz_act['res_id']) + wiz.process() + + # Now quantity received should be 3 again + self.assertEquals(order_line.qty_received, 3) + + default_data = self.env['stock.return.picking'].with_context( + active_ids=return_pick.ids, + active_id=return_pick.ids[0] + ).default_get(['move_dest_exists', 'original_location_id', 'product_return_moves', 'parent_location_id', 'location_id']) + return_wiz = self.env['stock.return.picking'].with_context( + active_ids=return_pick.ids, + active_id=return_pick.ids[0] + ).create(default_data) + for move in return_wiz.product_return_moves: + move.quantity = expected_quantities[move.product_id] + res = return_wiz.create_returns() + return_of_return_pick = self.env['stock.picking'].browse(res['res_id']) + + # Process all components except one of each + for move in return_of_return_pick.move_lines: + move.write({ + 'quantity_done': expected_quantities[move.product_id] - 1, + 'to_refund': True + }) + + backorder_wizard = self.env['stock.backorder.confirmation'].create({'pick_ids': [(4, return_of_return_pick.id)]}) + backorder_wizard.process() + + # As one of each component is missing, only 6 kit_parents should be received + self.assertEquals(order_line.qty_received, 6) + + # Check that the 4th backorder is created. + self.assertEquals(len(po.picking_ids), 7) + backorder_4 = po.picking_ids - ( + picking_original + backorder_1 + backorder_2 + backorder_3 + return_of_return_pick + return_pick) + self.assertEquals(backorder_4.backorder_id.id, return_of_return_pick.id) + + # Check the components quantities that backorder_4 should have + for move in backorder_4.move_lines: + self.assertEquals(move.product_qty, 1) |
