From c7db787061b6b1ece0c8f1a34ca394de70959e5b Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Thu, 21 Nov 2019 09:08:19 +0100 Subject: [PATCH 1/5] Use Savepoint in stock_available_mrp tests --- .../tests/test_potential_qty.py | 173 ++++++++++++------ 1 file changed, 113 insertions(+), 60 deletions(-) diff --git a/stock_available_mrp/tests/test_potential_qty.py b/stock_available_mrp/tests/test_potential_qty.py index 301a617bf..a94d8c612 100644 --- a/stock_available_mrp/tests/test_potential_qty.py +++ b/stock_available_mrp/tests/test_potential_qty.py @@ -2,84 +2,69 @@ # Copyright 2021 Tecnativa - Víctor Martínez # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +from odoo.tests.common import SavepointCase from odoo.osv.expression import TRUE_LEAF from odoo.tests.common import TransactionCase -class TestPotentialQty(TransactionCase): +class TestPotentialQty(SavepointCase): """Test the potential quantity on a product with a multi-line BoM""" - def setUp(self): - super().setUp() + @classmethod + def setUpClass(cls): + super(TestPotentialQty, cls).setUpClass() - self.product_model = self.env["product.product"] - self.bom_model = self.env["mrp.bom"] - self.bom_line_model = self.env["mrp.bom.line"] - self.stock_quant_model = self.env["stock.quant"] - self.config = self.env["ir.config_parameter"] - self.location = self.env["stock.location"] - self.main_company = self.browse_ref("base.main_company") + cls.product_model = cls.env["product.product"] + cls.bom_model = cls.env["mrp.bom"] + cls.bom_line_model = cls.env["mrp.bom.line"] + cls.stock_quant_model = cls.env["stock.quant"] + cls.config = cls.env['ir.config_parameter'] + cls.location = cls.env['stock.location'] # Get the warehouses - self.wh_main = self.browse_ref("stock.warehouse0") - self.wh_ch = self.browse_ref("stock.stock_warehouse_shop0") + cls.wh_main = cls.env.ref('stock.warehouse0') + cls.wh_ch = cls.env.ref('stock.stock_warehouse_shop0') + + # We need to compute parent_left and parent_right of the locations as + # they are used to compute qty_available of the product. + cls.location._parent_store_compute() + cls.setup_demo_data() + + @classmethod + def setup_demo_data(cls): #  An interesting product (multi-line BoM, variants) - self.tmpl = self.browse_ref("mrp.product_product_table_kit_product_template") + cls.tmpl = cls.env.ref( + 'mrp.product_product_build_kit_product_template') #  First variant - self.var1 = self.browse_ref("mrp.product_product_table_kit") + cls.var1 = cls.env.ref('mrp.product_product_build_kit') + cls.var1.type = 'product' #  Second variant - self.var2 = self.browse_ref("stock_available_mrp.product_kit_1a") - # Make bolt a stockable product to be able to change its stock - # we need to unreserve the existing move before being able to do it. - bolt = self.env.ref("mrp.product_product_computer_desk_bolt") - bolt.stock_move_ids._do_unreserve() - bolt.type = "product" + cls.var2 = cls.env.ref( + 'stock_available_mrp.product_kit_1a') + cls.var2.type = 'product' # Components that can be used to make the product - components = [ - # Bolt - bolt, - # Wood Panel - self.browse_ref("mrp.product_product_wood_panel"), - ] + components = ( + # KeyBoard + cls.env.ref('product.product_product_9') | + # Mouse + cls.env.ref('product.product_product_12') + ) # Zero-out the inventory of all variants and components - for component in components + [v for v in self.tmpl.product_variant_ids]: - moves = component.stock_move_ids.filtered( - lambda mo: mo.state not in ("done", "cancel") - ) - moves._action_cancel() - - component.stock_quant_ids.unlink() + for component in components + cls.tmpl.product_variant_ids: + cls.env['stock.quant'].search([ + ('product_id', '=', component.id) + ]).unlink() + cls.product_model.invalidate_cache() #  A product without a BoM - self.product_wo_bom = self.browse_ref("product.product_product_11") + cls.product_wo_bom = cls.env.ref('product.product_product_11') # Record the initial quantity available for sale - self.initial_usable_qties = { - i.id: i.immediately_usable_qty - for i in [self.tmpl, self.var1, self.var2, self.product_wo_bom] - } - - def _create_inventory(self, location_id, company_id): - inventory = self.env["stock.inventory"].create( - { - "name": "Test inventory", - "company_id": company_id, - "location_ids": [(4, location_id)], - "start_empty": True, - } - ) - inventory.action_start() - return inventory - - def _create_inventory_line(self, inventory_id, product_id, location_id, qty): - self.env["stock.inventory.line"].create( - { - "inventory_id": inventory_id, - "product_id": product_id, - "location_id": location_id, - "product_qty": qty, - } - ) + cls.initial_usable_qties = {i.id: i.immediately_usable_qty + for i in [cls.tmpl, + cls.var1, + cls.var2, + cls.product_wo_bom]} def create_inventory(self, product_id, qty, location_id=None, company_id=None): if location_id is None: @@ -368,6 +353,74 @@ class TestPotentialQty(TransactionCase): p1.refresh() self.assertEqual(2.0, p1.potential_qty) + def test_component_stock_choice(self): + # Test to change component stock for compute BOM stock + + # Get a demo product with outgoing move (qty: 3) + prod = self.env.ref('product.product_product_20') + + # Set on hand qty + self.create_inventory(prod.id, 3) + + # Create a product with BOM + p1 = self.product_model.create({ + 'name': 'Test product with BOM', + }) + bom_p1 = self.bom_model.create({ + 'product_tmpl_id': p1.product_tmpl_id.id, + 'product_id': p1.id, + 'product_qty': 1, + }) + + # Need 1 prod for that + self.bom_line_model.create({ + 'bom_id': bom_p1.id, + 'product_id': prod.id, + 'product_qty': 1, + }) + + # Default component is qty_available + p1.refresh() + self.assertEqual(3.0, p1.potential_qty) + + # Change to immediately usable + self.config.set_param('stock_available_mrp_based_on', + 'immediately_usable_qty') + + p1.refresh() + self.assertEqual(0.0, p1.potential_qty) + + # If iMac has a Bom and can be manufactured + component = self.product_model.create({ + 'name': 'component', + 'type': 'product' + }) + self.create_inventory(component.id, 5) + + imac_bom = self.bom_model.create({ + 'product_tmpl_id': prod.product_tmpl_id.id, + 'product_id': prod.id, + 'product_qty': 1, + 'type': 'phantom', + }) + + # Need 1 component for prod + self.bom_line_model.create({ + 'bom_id': imac_bom.id, + 'product_id': component.id, + 'product_qty': 1, + }) + + p1.refresh() + self.assertEqual(5.0, p1.potential_qty) + + # Changing to virtual (same as immediately in current config) + self.config.set_param('stock_available_mrp_based_on', + 'virtual_available') + p1.refresh() + + self.assertEqual(5.0, p1.potential_qty) + def test_potential_qty_list(self): # Try to highlight a bug when _get_potential_qty is called on # a recordset with multiple products From a8c547d22c81c066678dd1a768f49080a5fa0e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Pigeon?= Date: Fri, 1 Oct 2021 16:52:52 +0200 Subject: [PATCH 2/5] [FIX] adapt back to V14 --- .../tests/test_potential_qty.py | 157 +++++++----------- 1 file changed, 57 insertions(+), 100 deletions(-) diff --git a/stock_available_mrp/tests/test_potential_qty.py b/stock_available_mrp/tests/test_potential_qty.py index a94d8c612..917926579 100644 --- a/stock_available_mrp/tests/test_potential_qty.py +++ b/stock_available_mrp/tests/test_potential_qty.py @@ -2,9 +2,8 @@ # Copyright 2021 Tecnativa - Víctor Martínez # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from odoo.tests.common import SavepointCase from odoo.osv.expression import TRUE_LEAF -from odoo.tests.common import TransactionCase +from odoo.tests.common import SavepointCase class TestPotentialQty(SavepointCase): @@ -18,11 +17,12 @@ class TestPotentialQty(SavepointCase): cls.bom_model = cls.env["mrp.bom"] cls.bom_line_model = cls.env["mrp.bom.line"] cls.stock_quant_model = cls.env["stock.quant"] - cls.config = cls.env['ir.config_parameter'] - cls.location = cls.env['stock.location'] + cls.config = cls.env["ir.config_parameter"] + cls.location = cls.env["stock.location"] + cls.main_company = cls.env.ref("base.main_company") # Get the warehouses - cls.wh_main = cls.env.ref('stock.warehouse0') - cls.wh_ch = cls.env.ref('stock.stock_warehouse_shop0') + cls.wh_main = cls.env.ref("stock.warehouse0") + cls.wh_ch = cls.env.ref("stock.stock_warehouse_shop0") # We need to compute parent_left and parent_right of the locations as # they are used to compute qty_available of the product. @@ -32,39 +32,64 @@ class TestPotentialQty(SavepointCase): @classmethod def setup_demo_data(cls): #  An interesting product (multi-line BoM, variants) - cls.tmpl = cls.env.ref( - 'mrp.product_product_build_kit_product_template') + cls.tmpl = cls.env.ref("mrp.product_product_table_kit_product_template") #  First variant - cls.var1 = cls.env.ref('mrp.product_product_build_kit') - cls.var1.type = 'product' + cls.var1 = cls.env.ref("mrp.product_product_table_kit") + cls.var1.type = "product" #  Second variant - cls.var2 = cls.env.ref( - 'stock_available_mrp.product_kit_1a') - cls.var2.type = 'product' + cls.var2 = cls.env.ref("stock_available_mrp.product_kit_1a") + cls.var2.type = "product" + # Make bolt a stockable product to be able to change its stock + # we need to unreserve the existing move before being able to do it. + bolt = cls.env.ref("mrp.product_product_computer_desk_bolt") + bolt.stock_move_ids._do_unreserve() + bolt.type = "product" # Components that can be used to make the product - components = ( - # KeyBoard - cls.env.ref('product.product_product_9') | - # Mouse - cls.env.ref('product.product_product_12') - ) - + components = [ + # Bolt + bolt, + # Wood Panel + cls.env.ref("mrp.product_product_wood_panel"), + ] # Zero-out the inventory of all variants and components - for component in components + cls.tmpl.product_variant_ids: - cls.env['stock.quant'].search([ - ('product_id', '=', component.id) - ]).unlink() + for component in components + [v for v in cls.tmpl.product_variant_ids]: + moves = component.stock_move_ids.filtered( + lambda mo: mo.state not in ("done", "cancel") + ) + moves._action_cancel() - cls.product_model.invalidate_cache() - #  A product without a BoM - cls.product_wo_bom = cls.env.ref('product.product_product_11') + component.stock_quant_ids.unlink() + + # A product without a BoM + cls.product_wo_bom = cls.env.ref("product.product_product_11") # Record the initial quantity available for sale - cls.initial_usable_qties = {i.id: i.immediately_usable_qty - for i in [cls.tmpl, - cls.var1, - cls.var2, - cls.product_wo_bom]} + cls.initial_usable_qties = { + i.id: i.immediately_usable_qty + for i in [cls.tmpl, cls.var1, cls.var2, cls.product_wo_bom] + } + + def _create_inventory(self, location_id, company_id): + inventory = self.env["stock.inventory"].create( + { + "name": "Test inventory", + "company_id": company_id, + "location_ids": [(4, location_id)], + "start_empty": True, + } + ) + inventory.action_start() + return inventory + + def _create_inventory_line(self, inventory_id, product_id, location_id, qty): + self.env["stock.inventory.line"].create( + { + "inventory_id": inventory_id, + "product_id": product_id, + "location_id": location_id, + "product_qty": qty, + } + ) def create_inventory(self, product_id, qty, location_id=None, company_id=None): if location_id is None: @@ -353,74 +378,6 @@ class TestPotentialQty(SavepointCase): p1.refresh() self.assertEqual(2.0, p1.potential_qty) - def test_component_stock_choice(self): - # Test to change component stock for compute BOM stock - - # Get a demo product with outgoing move (qty: 3) - prod = self.env.ref('product.product_product_20') - - # Set on hand qty - self.create_inventory(prod.id, 3) - - # Create a product with BOM - p1 = self.product_model.create({ - 'name': 'Test product with BOM', - }) - bom_p1 = self.bom_model.create({ - 'product_tmpl_id': p1.product_tmpl_id.id, - 'product_id': p1.id, - 'product_qty': 1, - }) - - # Need 1 prod for that - self.bom_line_model.create({ - 'bom_id': bom_p1.id, - 'product_id': prod.id, - 'product_qty': 1, - }) - - # Default component is qty_available - p1.refresh() - self.assertEqual(3.0, p1.potential_qty) - - # Change to immediately usable - self.config.set_param('stock_available_mrp_based_on', - 'immediately_usable_qty') - - p1.refresh() - self.assertEqual(0.0, p1.potential_qty) - - # If iMac has a Bom and can be manufactured - component = self.product_model.create({ - 'name': 'component', - 'type': 'product' - }) - self.create_inventory(component.id, 5) - - imac_bom = self.bom_model.create({ - 'product_tmpl_id': prod.product_tmpl_id.id, - 'product_id': prod.id, - 'product_qty': 1, - 'type': 'phantom', - }) - - # Need 1 component for prod - self.bom_line_model.create({ - 'bom_id': imac_bom.id, - 'product_id': component.id, - 'product_qty': 1, - }) - - p1.refresh() - self.assertEqual(5.0, p1.potential_qty) - - # Changing to virtual (same as immediately in current config) - self.config.set_param('stock_available_mrp_based_on', - 'virtual_available') - p1.refresh() - - self.assertEqual(5.0, p1.potential_qty) - def test_potential_qty_list(self): # Try to highlight a bug when _get_potential_qty is called on # a recordset with multiple products From 5dc3fc08a1a223825960e71ab39e8f18a235e4f3 Mon Sep 17 00:00:00 2001 From: Guewen Baconnier Date: Wed, 20 Nov 2019 16:40:10 +0100 Subject: [PATCH 3/5] Improve performance of stock available of BoMs Use an optimized method to explode the BoM. The explode methods include data and a cycle check that we don't need here. Besides, it calls '_bom_find' on every bom line of the graph, which generates thousands of SELECT queries on large BoMs. --- stock_available_mrp/models/product_product.py | 86 +++++++++++++++++-- 1 file changed, 78 insertions(+), 8 deletions(-) diff --git a/stock_available_mrp/models/product_product.py b/stock_available_mrp/models/product_product.py index dfa1834f3..11321faf0 100644 --- a/stock_available_mrp/models/product_product.py +++ b/stock_available_mrp/models/product_product.py @@ -5,6 +5,7 @@ from collections import Counter from odoo import api, fields, models from odoo.fields import first +from odoo.tools import float_round class ProductProduct(models.Model): @@ -82,7 +83,8 @@ class ProductProduct(models.Model): for product in product_with_bom: # Need by product (same product can be in many BOM lines/levels) exploded_components = exploded_boms[product.id] - component_needs = product._get_components_needs(exploded_components) + component_needs = product._get_components_needs( + exploded_components) if not component_needs: # The BoM has no line we can use potential_qty = immediately_usable_qty = 0.0 @@ -119,10 +121,7 @@ class ProductProduct(models.Model): return a dict by product_id of exploded bom lines :return: """ - exploded_boms = {} - for rec in self: - exploded_boms[rec.id] = rec.bom_id.explode(rec, 1.0)[1] - return exploded_boms + return self.explode_bom_quantities() @api.model def _get_components_needs(self, exploded_components): @@ -132,8 +131,79 @@ class ProductProduct(models.Model): :rtype: collections.Counter """ needs = Counter() - for bom_component in exploded_components: - component = bom_component[0].product_id - needs += Counter({component: bom_component[1]["qty"]}) + for bom_line, bom_qty in exploded_components: + component = bom_line.product_id + needs += Counter({component: bom_qty}) return needs + + def explode_bom_quantities(self): + """Explode a bill of material with quantities to consume + + It returns a dict with the exploded bom lines and + the quantity they consume. Example:: + + { + : [ + (, ) + (, ) + ] + } + + The 'MrpBom.explode()' method includes the same information, with other + things, but is under-optimized to be used for the purpose of this + module. The killer is particularly the call to `_bom_find()` which can + generate thousands of SELECT for searches. + """ + result = {} + + for product in self: + lines_done = [] + bom_lines = [ + (product.bom_id, bom_line, product, 1.0) + for bom_line in product.bom_id.bom_line_ids + ] + + while bom_lines: + (current_bom, + current_line, + current_product, + current_qty) = bom_lines[0] + bom_lines = bom_lines[1:] + + if current_line._skip_bom_line(current_product): + continue + + line_quantity = current_qty * current_line.product_qty + + sub_bom = current_line.product_id.bom_id + if sub_bom.type == 'phantom': + product_uom = current_line.product_uom_id + converted_line_quantity = product_uom._compute_quantity( + line_quantity / sub_bom.product_qty, + sub_bom.product_uom_id, + ) + bom_lines = [ + ( + sub_bom, + line, + current_line.product_id, + converted_line_quantity, + ) + for line in sub_bom.bom_line_ids + ] + bom_lines + else: + # We round up here because the user expects that if he has + # to consume a little more, the whole UOM unit should be + # consumed. + rounding = current_line.product_uom_id.rounding + line_quantity = float_round( + line_quantity, + precision_rounding=rounding, + rounding_method='UP', + ) + lines_done.append((current_line, line_quantity)) + + result[product.id] = lines_done + + return result From 08a708a649a29c98fd09ae362573aa14aac60b57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Pigeon?= Date: Fri, 1 Oct 2021 17:25:23 +0200 Subject: [PATCH 4/5] [14.0] stock_available_mrp: improve performance by using standard field --- stock_available_mrp/models/product_product.py | 65 +++++-------------- 1 file changed, 15 insertions(+), 50 deletions(-) diff --git a/stock_available_mrp/models/product_product.py b/stock_available_mrp/models/product_product.py index 11321faf0..1034ba2b8 100644 --- a/stock_available_mrp/models/product_product.py +++ b/stock_available_mrp/models/product_product.py @@ -3,7 +3,7 @@ from collections import Counter -from odoo import api, fields, models +from odoo import api, models from odoo.fields import first from odoo.tools import float_round @@ -11,44 +11,14 @@ from odoo.tools import float_round class ProductProduct(models.Model): _inherit = "product.product" - bom_id = fields.Many2one( - comodel_name="mrp.bom", compute="_compute_bom_id", string="BOM" - ) - - @api.depends("virtual_available", "bom_id", "bom_id.product_qty") + @api.depends("virtual_available", "bom_ids", "bom_ids.product_qty") def _compute_available_quantities(self): super()._compute_available_quantities() - def _get_bom_id_domain(self): - """ - Real multi domain - :return: - """ - return [ - "|", - ("product_id", "in", self.ids), - "&", - ("product_id", "=", False), - ("product_tmpl_id", "in", self.mapped("product_tmpl_id.id")), - ] - - @api.depends("product_tmpl_id") - def _compute_bom_id(self): - bom_obj = self.env["mrp.bom"] - boms = bom_obj.search(self._get_bom_id_domain(), order="sequence, product_id") - for product in self: - product.bom_id = product.bom_id - product_boms = boms.filtered( - lambda b: b.product_id == product - or (not b.product_id and b.product_tmpl_id == product.product_tmpl_id) - ) - if product_boms: - product.bom_id = first(product_boms) - def _compute_available_quantities_dict(self): res, stock_dict = super()._compute_available_quantities_dict() # compute qty for product with bom - product_with_bom = self.filtered("bom_id") + product_with_bom = self.filtered("bom_ids") if not product_with_bom: return res, stock_dict @@ -83,8 +53,7 @@ class ProductProduct(models.Model): for product in product_with_bom: # Need by product (same product can be in many BOM lines/levels) exploded_components = exploded_boms[product.id] - component_needs = product._get_components_needs( - exploded_components) + component_needs = product._get_components_needs(exploded_components) if not component_needs: # The BoM has no line we can use potential_qty = immediately_usable_qty = 0.0 @@ -97,21 +66,20 @@ class ProductProduct(models.Model): for component, need in component_needs.items() ] ) - potential_qty = product.bom_id.product_qty * components_potential_qty + bom_id = first(product.bom_ids) + potential_qty = bom_id.product_qty * components_potential_qty potential_qty = potential_qty > 0.0 and potential_qty or 0.0 # We want to respect the rounding factor of the potential_qty # Rounding down as we want to be pesimistic. - potential_qty = product.bom_id.product_uom_id._compute_quantity( + potential_qty = bom_id.product_uom_id._compute_quantity( potential_qty, - product.bom_id.product_tmpl_id.uom_id, + bom_id.product_tmpl_id.uom_id, rounding_method="DOWN", ) res[product.id]["potential_qty"] = potential_qty - immediately_usable_qty = ( - potential_qty if product.bom_id.type != "phantom" else 0 - ) + immediately_usable_qty = potential_qty if bom_id.type != "phantom" else 0 res[product.id]["immediately_usable_qty"] += immediately_usable_qty return res, stock_dict @@ -160,15 +128,12 @@ class ProductProduct(models.Model): for product in self: lines_done = [] bom_lines = [ - (product.bom_id, bom_line, product, 1.0) - for bom_line in product.bom_id.bom_line_ids + (first(product.bom_ids), bom_line, product, 1.0) + for bom_line in first(product.bom_ids).bom_line_ids ] while bom_lines: - (current_bom, - current_line, - current_product, - current_qty) = bom_lines[0] + (current_bom, current_line, current_product, current_qty) = bom_lines[0] bom_lines = bom_lines[1:] if current_line._skip_bom_line(current_product): @@ -176,8 +141,8 @@ class ProductProduct(models.Model): line_quantity = current_qty * current_line.product_qty - sub_bom = current_line.product_id.bom_id - if sub_bom.type == 'phantom': + sub_bom = first(current_line.product_id.bom_ids) + if sub_bom.type == "phantom": product_uom = current_line.product_uom_id converted_line_quantity = product_uom._compute_quantity( line_quantity / sub_bom.product_qty, @@ -200,7 +165,7 @@ class ProductProduct(models.Model): line_quantity = float_round( line_quantity, precision_rounding=rounding, - rounding_method='UP', + rounding_method="UP", ) lines_done.append((current_line, line_quantity)) From a4c6c5b8bfb810033efe75127961d314f451784a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Pigeon?= Date: Fri, 1 Oct 2021 17:28:48 +0200 Subject: [PATCH 5/5] [14.0] stock_available: improve search performance --- stock_available/models/product_product.py | 10 +++++++--- stock_available_mrp/models/product_product.py | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/stock_available/models/product_product.py b/stock_available/models/product_product.py index d50f5f87e..c03bf72cc 100644 --- a/stock_available/models/product_product.py +++ b/stock_available/models/product_product.py @@ -67,6 +67,9 @@ class ProductProduct(models.Model): "the materials already at hand.", ) + def _get_search_immediately_usable_qty_domain(self): + return [("type", "=", "product")] + @api.model def _search_immediately_usable_qty(self, operator, value): """Search function for the immediately_usable_qty field. @@ -76,9 +79,10 @@ class ProductProduct(models.Model): :param value: str :return: list of tuple (domain) """ - products = self.search([]) - # Force prefetch - products.mapped("immediately_usable_qty") + product_domain = self._get_search_immediately_usable_qty_domain() + products = self.with_context(prefetch_fields=False).search( + product_domain, order="id" + ) product_ids = [] for product in products: if OPERATORS[operator](product.immediately_usable_qty, value): diff --git a/stock_available_mrp/models/product_product.py b/stock_available_mrp/models/product_product.py index 1034ba2b8..e6ee21ede 100644 --- a/stock_available_mrp/models/product_product.py +++ b/stock_available_mrp/models/product_product.py @@ -52,6 +52,7 @@ class ProductProduct(models.Model): for product in product_with_bom: # Need by product (same product can be in many BOM lines/levels) + bom_id = first(product.bom_ids) exploded_components = exploded_boms[product.id] component_needs = product._get_components_needs(exploded_components) if not component_needs: @@ -66,7 +67,6 @@ class ProductProduct(models.Model): for component, need in component_needs.items() ] ) - bom_id = first(product.bom_ids) potential_qty = bom_id.product_qty * components_potential_qty potential_qty = potential_qty > 0.0 and potential_qty or 0.0