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 dfa1834f3..e6ee21ede 100644 --- a/stock_available_mrp/models/product_product.py +++ b/stock_available_mrp/models/product_product.py @@ -3,51 +3,22 @@ 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 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 @@ -81,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: @@ -95,21 +67,19 @@ class ProductProduct(models.Model): for component, need in component_needs.items() ] ) - potential_qty = product.bom_id.product_qty * components_potential_qty + 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 @@ -119,10 +89,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 +99,76 @@ 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 = [ + (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] + bom_lines = bom_lines[1:] + + if current_line._skip_bom_line(current_product): + continue + + line_quantity = current_qty * current_line.product_qty + + 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, + 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 diff --git a/stock_available_mrp/tests/test_potential_qty.py b/stock_available_mrp/tests/test_potential_qty.py index 301a617bf..917926579 100644 --- a/stock_available_mrp/tests/test_potential_qty.py +++ b/stock_available_mrp/tests/test_potential_qty.py @@ -3,34 +3,45 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). from odoo.osv.expression import TRUE_LEAF -from odoo.tests.common import TransactionCase +from odoo.tests.common import SavepointCase -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"] + cls.main_company = cls.env.ref("base.main_company") # 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_table_kit_product_template") #  First variant - self.var1 = self.browse_ref("mrp.product_product_table_kit") + cls.var1 = cls.env.ref("mrp.product_product_table_kit") + cls.var1.type = "product" #  Second variant - self.var2 = self.browse_ref("stock_available_mrp.product_kit_1a") + 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 = self.env.ref("mrp.product_product_computer_desk_bolt") + 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 @@ -38,11 +49,10 @@ class TestPotentialQty(TransactionCase): # Bolt bolt, # Wood Panel - self.browse_ref("mrp.product_product_wood_panel"), + cls.env.ref("mrp.product_product_wood_panel"), ] - # Zero-out the inventory of all variants and components - for component in components + [v for v in self.tmpl.product_variant_ids]: + 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") ) @@ -50,13 +60,13 @@ class TestPotentialQty(TransactionCase): component.stock_quant_ids.unlink() - #  A product without a BoM - self.product_wo_bom = self.browse_ref("product.product_product_11") + # A product without a BoM + cls.product_wo_bom = cls.env.ref("product.product_product_11") # Record the initial quantity available for sale - self.initial_usable_qties = { + cls.initial_usable_qties = { i.id: i.immediately_usable_qty - for i in [self.tmpl, self.var1, self.var2, self.product_wo_bom] + for i in [cls.tmpl, cls.var1, cls.var2, cls.product_wo_bom] } def _create_inventory(self, location_id, company_id):