diff --git a/stock_available_mrp/README.rst b/stock_available_mrp/README.rst index 99e7569b8..b47d8dbf0 100644 --- a/stock_available_mrp/README.rst +++ b/stock_available_mrp/README.rst @@ -1,30 +1,64 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +========================================================= Consider the production potential is available to promise ========================================================= -This module takes the potential quantities available for Products in account in +This module takes the potential quantities available for Products into account in the quantity available to promise, where the "Potential quantity" is the quantity that can be manufactured with the components immediately at hand. +By configuration, the "Potential quantity" can be computed based on other product field. +For example, "Potential quantity" can be the quantity that can be manufactured +with the components available to promise. + +Usage +===== + +.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas + :alt: Try me on Runbot + :target: https://runbot.odoo-community.org/runbot/153/8.0 + +Known issues / Roadmap +====================== Known issues -============ - +------------ The manufacturing delays are not taken into account : this module assumes that if you have components in stock goods, you can manufacture finished goods quickly enough. -To avoid overestimating, **only the first level** of Bill of Materials is + +As a consequence, and to avoid overestimating, **only the first level** of Bill of Materials is considered. +However Sets (a.k.a "phantom" BoMs) are taken into account: if a component must be replaced with a set, it's the stock of the set's product which will decide the potential. + +If a product has several variants, only the variant with the biggest potential will be taken into account when reporting the production potential. +For example, even if you actually have enough components to make 10 iPads 16Go AND 42 iPads 32Go, we'll consider that you can promise only 42 iPads. + +Removed features +---------------- +Previous versions of this module used to let programmers demand to get the potential quantity in an arbitrary Unit of Measure using the `context`. This feature was present in the standard computations too until v8.0, but it has been dropped from the standard from v8.0 on. +For the sake of consistency the potential quantity is now always reported in the product's main Unit of Measure too. Roadmap ------- +Possible improvements for future versions: +* take manufacturing delays into account: we should not promise goods to customers if they want them delivered earlier that we can make them +* Compute the quantity of finished product that can be made directly on each Bill of Material: this would be useful for production managers, and may make the computations faster by avoiding to compute the same BoM several times when several variants share the same BoM +* add an option (probably as a sub-module) to consider all raw materials as available if they can be bought from the suppliers in time for the manufacturing. -* include all levels of BoM, using `bom_explode`. @gdgellatly gave an example - of how to do it here: https://github.com/OCA/stock-logistics-warehouse/pull/5#issuecomment-66902191 - Ideally, we will want to take manufacturing delays into account: we can't - promiss goods to customers if they want them delivered earlier that we can - make them -* add an option (probably as a sub-module) to consider all raw materials as - available if they can be bought from the suppliers in time for the - manufacturing. +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues +`_. In case of trouble, please +check there if your issue has already been reported. If you spotted it first, +help us smashing it by providing a detailed and welcomed `feedback +`_. Credits ======= @@ -33,16 +67,20 @@ Contributors ------------ * Loïc Bellier (Numérigraphe) * Lionel Sausin (Numérigraphe) +* many thanks to Graeme Gellatly for his advice and code review +* Laurent Mignon Maintainer ---------- -.. image:: http://odoo-community.org/logo.png +.. image:: https://odoo-community.org/logo.png :alt: Odoo Community Association - :target: http://odoo-community.org + :target: https://odoo-community.org This module is maintained by the OCA. -OCA, or the Odoo Community Association, is a nonprofit organization whose mission is to support the collaborative development of Odoo features and promote its widespread use. +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. To contribute to this module, please visit http://odoo-community.org. diff --git a/stock_available_mrp/__init__.py b/stock_available_mrp/__init__.py index 302cef51c..0443836b4 100644 --- a/stock_available_mrp/__init__.py +++ b/stock_available_mrp/__init__.py @@ -1,21 +1,5 @@ # -*- coding: utf-8 -*- -############################################################################## -# -# This module is copyright (C) 2014 Numérigraphe SARL. All Rights Reserved. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -# -############################################################################## +# © 2014 Numérigraphe SARL +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). -from . import product +from . import models diff --git a/stock_available_mrp/__openerp__.py b/stock_available_mrp/__openerp__.py index 7e00810e0..0aa748b7e 100644 --- a/stock_available_mrp/__openerp__.py +++ b/stock_available_mrp/__openerp__.py @@ -1,35 +1,22 @@ # -*- coding: utf-8 -*- -############################################################################## -# -# This module is copyright (C) 2014 Numérigraphe SARL. All Rights Reserved. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -# -############################################################################## - +# © 2014 Numérigraphe SARL, Camptocamp +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). { 'name': 'Consider the production potential is available to promise', - 'version': '2.0', - "author": u"Numérigraphe,Odoo Community Association (OCA)", + 'version': '9.0.1.0.0', + "author": u"Numérigraphe," + u"Odoo Community Association (OCA)", 'category': 'Hidden', - 'depends': ['stock_available', 'mrp'], - 'data': [ - 'product_view.xml', + 'depends': [ + 'stock_available', + 'mrp' ], - 'test': [ - 'test/potential_qty.yml', + 'data': [ + 'views/product_template_view.xml', + ], + 'demo': [ + 'demo/mrp_bom.yml', ], 'license': 'AGPL-3', - 'installable': False + 'installable': True, } diff --git a/stock_available_mrp/demo/mrp_bom.yml b/stock_available_mrp/demo/mrp_bom.yml new file mode 100644 index 000000000..bb197f44b --- /dev/null +++ b/stock_available_mrp/demo/mrp_bom.yml @@ -0,0 +1,27 @@ +- Create a UoM in the category of PCE +- !record {model: product.uom, id: thousand}: + name: Thousand + factor: 0.001 + rounding: 0.001 + uom_type: bigger + category_id: product.product_uom_categ_unit + +- Add a BOM whereby 0.042K "RAM SR2" can be replaced with 13 dozens "HDD-SH1" + 8 CPUa8 with 50% efficiency. This lets us test UoM conversions for the finished product and the raw materials, as well as the unfolding of phantom BoMs +- !record {model: mrp.bom, id: sr2_from_hdd}: + product_id: product.product_product_14 + product_tmpl_id: product.product_product_14_product_template + product_uom: thousand + product_qty: 0.042 + type: phantom + sequence: -1 + product_efficiency: 0.5 +- !record {model: mrp.bom.line, id: sr2_from_hdd_line1}: + bom_id: sr2_from_hdd + product_id: product.product_product_18 + product_qty: 13 + product_uom: product.product_uom_dozen +- !record {model: mrp.bom.line, id: sr2_from_hdd_line2}: + bom_id: sr2_from_hdd + product_id: product.product_product_23 + product_qty: 8 + product_uom: product.product_uom_unit diff --git a/stock_available_mrp/i18n/de.po b/stock_available_mrp/i18n/de.po new file mode 100644 index 000000000..3fc625b7f --- /dev/null +++ b/stock_available_mrp/i18n/de.po @@ -0,0 +1,34 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_available_mrp +# +# Translators: +# Rudolf Schnapka , 2016 +msgid "" +msgstr "" +"Project-Id-Version: stock-logistics-warehouse (8.0)\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2016-01-14 01:38+0000\n" +"PO-Revision-Date: 2016-01-14 09:35+0000\n" +"Last-Translator: Rudolf Schnapka \n" +"Language-Team: German (http://www.transifex.com/oca/OCA-stock-logistics-warehouse-8-0/language/de/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: de\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: stock_available_mrp +#: model:ir.model,name:stock_available_mrp.model_product_product +msgid "Product" +msgstr "Produkt" + +#. module: stock_available_mrp +#: model:ir.model,name:stock_available_mrp.model_product_template +msgid "Product Template" +msgstr "Produktvorlage" + +#. module: stock_available_mrp +#: model:product.uom,name:stock_available_mrp.thousand +msgid "Thousand" +msgstr "Tausend" diff --git a/stock_available_mrp/i18n/es.po b/stock_available_mrp/i18n/es.po new file mode 100644 index 000000000..aab2eb182 --- /dev/null +++ b/stock_available_mrp/i18n/es.po @@ -0,0 +1,33 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_available_mrp +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: stock-logistics-warehouse (8.0)\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2016-01-14 01:38+0000\n" +"PO-Revision-Date: 2016-01-13 16:35+0000\n" +"Last-Translator: <>\n" +"Language-Team: Spanish (http://www.transifex.com/oca/OCA-stock-logistics-warehouse-8-0/language/es/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: es\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: stock_available_mrp +#: model:ir.model,name:stock_available_mrp.model_product_product +msgid "Product" +msgstr "Producto" + +#. module: stock_available_mrp +#: model:ir.model,name:stock_available_mrp.model_product_template +msgid "Product Template" +msgstr "Plantilla de producto" + +#. module: stock_available_mrp +#: model:product.uom,name:stock_available_mrp.thousand +msgid "Thousand" +msgstr "" diff --git a/stock_available_mrp/i18n/fi.po b/stock_available_mrp/i18n/fi.po new file mode 100644 index 000000000..fa81ddfe0 --- /dev/null +++ b/stock_available_mrp/i18n/fi.po @@ -0,0 +1,33 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_available_mrp +# +# Translators: +msgid "" +msgstr "" +"Project-Id-Version: stock-logistics-warehouse (8.0)\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2016-01-14 01:38+0000\n" +"PO-Revision-Date: 2016-01-13 16:35+0000\n" +"Last-Translator: <>\n" +"Language-Team: Finnish (http://www.transifex.com/oca/OCA-stock-logistics-warehouse-8-0/language/fi/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: fi\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: stock_available_mrp +#: model:ir.model,name:stock_available_mrp.model_product_product +msgid "Product" +msgstr "Tuote" + +#. module: stock_available_mrp +#: model:ir.model,name:stock_available_mrp.model_product_template +msgid "Product Template" +msgstr "Tuotteen malli" + +#. module: stock_available_mrp +#: model:product.uom,name:stock_available_mrp.thousand +msgid "Thousand" +msgstr "" diff --git a/stock_available_mrp/i18n/fr.po b/stock_available_mrp/i18n/fr.po index 860df2cc3..0aaafddd3 100644 --- a/stock_available_mrp/i18n/fr.po +++ b/stock_available_mrp/i18n/fr.po @@ -1,34 +1,33 @@ -# Translation of OpenERP Server. +# Translation of Odoo Server. # This file contains the translation of the following modules: -# * stock_available_mrp -# +# * stock_available_mrp +# +# Translators: msgid "" msgstr "" -"Project-Id-Version: OpenERP Server 7.0\n" +"Project-Id-Version: stock-logistics-warehouse (8.0)\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2014-07-30 19:29+0000\n" -"PO-Revision-Date: 2014-07-30 19:29+0000\n" +"POT-Creation-Date: 2016-01-14 01:38+0000\n" +"PO-Revision-Date: 2016-01-13 16:35+0000\n" "Last-Translator: <>\n" -"Language-Team: \n" +"Language-Team: French (http://www.transifex.com/oca/OCA-stock-logistics-warehouse-8-0/language/fr/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: \n" -"Plural-Forms: \n" +"Language: fr\n" +"Plural-Forms: nplurals=2; plural=(n > 1);\n" #. module: stock_available_mrp -#: field:product.product,potential_qty:0 -msgid "Potential" -msgstr "Potentiel" - -#. module: stock_available_mrp -#: code:_description:0 #: model:ir.model,name:stock_available_mrp.model_product_product -#, python-format msgid "Product" msgstr "Article" #. module: stock_available_mrp -#: help:product.product,potential_qty:0 -msgid "Quantity of this Product that could be produced using the materials already at hand, following a single level of the Bills of Materials." -msgstr "Quantité de cet article que l'on pourrait produire en utilisant les produits déjà disponibles, en suivant un seul niveau de nomenclature." +#: model:ir.model,name:stock_available_mrp.model_product_template +msgid "Product Template" +msgstr "Modèle de produit" +#. module: stock_available_mrp +#: model:product.uom,name:stock_available_mrp.thousand +msgid "Thousand" +msgstr "" diff --git a/stock_available_mrp/i18n/sl.po b/stock_available_mrp/i18n/sl.po new file mode 100644 index 000000000..753889eea --- /dev/null +++ b/stock_available_mrp/i18n/sl.po @@ -0,0 +1,34 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * stock_available_mrp +# +# Translators: +# Matjaž Mozetič , 2016 +msgid "" +msgstr "" +"Project-Id-Version: stock-logistics-warehouse (8.0)\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2016-01-14 01:38+0000\n" +"PO-Revision-Date: 2016-01-14 05:18+0000\n" +"Last-Translator: Matjaž Mozetič \n" +"Language-Team: Slovenian (http://www.transifex.com/oca/OCA-stock-logistics-warehouse-8-0/language/sl/)\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Language: sl\n" +"Plural-Forms: nplurals=4; plural=(n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3);\n" + +#. module: stock_available_mrp +#: model:ir.model,name:stock_available_mrp.model_product_product +msgid "Product" +msgstr "Proizvod" + +#. module: stock_available_mrp +#: model:ir.model,name:stock_available_mrp.model_product_template +msgid "Product Template" +msgstr "Predloga proizvoda" + +#. module: stock_available_mrp +#: model:product.uom,name:stock_available_mrp.thousand +msgid "Thousand" +msgstr "Tisoč" diff --git a/stock_available_mrp/i18n/stock_available_mrp.pot b/stock_available_mrp/i18n/stock_available_mrp.pot index b1cffcca2..ae3a04141 100644 --- a/stock_available_mrp/i18n/stock_available_mrp.pot +++ b/stock_available_mrp/i18n/stock_available_mrp.pot @@ -29,6 +29,5 @@ msgstr "" #. module: stock_available_mrp #: help:product.product,potential_qty:0 -msgid "Quantity of this Product that could be produced using the materials already at hand, following a single level of the Bills of Materials." +msgid "Quantity of this Product that could be produced using the materials already at hand." msgstr "" - diff --git a/stock_available_mrp/models/__init__.py b/stock_available_mrp/models/__init__.py new file mode 100644 index 000000000..defb121af --- /dev/null +++ b/stock_available_mrp/models/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# © 2014 Numérigraphe SARL +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import product_product +from . import product_template diff --git a/stock_available_mrp/models/product_product.py b/stock_available_mrp/models/product_product.py new file mode 100644 index 000000000..790ca16a4 --- /dev/null +++ b/stock_available_mrp/models/product_product.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# © 2014 Numérigraphe SARL +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from collections import Counter + +from openerp import models, fields, api +from openerp.addons import decimal_precision as dp + +from openerp.exceptions import AccessError + + +class ProductProduct(models.Model): + _inherit = 'product.product' + + potential_qty = fields.Float( + compute='_get_potential_qty', + type='float', + digits_compute=dp.get_precision('Product Unit of Measure'), + string='Potential', + help="Quantity of this Product that could be produced using " + "the materials already at hand.") + + # Needed for fields dependencies + # When self.potential_qty is compute, we want to force the ORM + # to compute all the components potential_qty too. + component_ids = fields.Many2many( + comodel_name='product.product', + compute='_get_component_ids', + ) + + @api.multi + @api.depends('potential_qty') + def _immediately_usable_qty(self): + """Add the potential quantity to the quantity available to promise. + + This is the same implementation as for templates.""" + super(ProductProduct, self)._immediately_usable_qty() + for product in self: + product.immediately_usable_qty += product.potential_qty + + @api.multi + @api.depends('component_ids.potential_qty') + def _get_potential_qty(self): + """Compute the potential qty based on the available components.""" + bom_obj = self.env['mrp.bom'] + uom_obj = self.env['product.uom'] + + for product in self: + bom_id = bom_obj._bom_find(product_id=product.id) + if not bom_id: + product.potential_qty = 0.0 + continue + + bom = bom_obj.browse(bom_id) + + # Need by product (same product can be in many BOM lines/levels) + try: + component_needs = self._get_components_needs(product, bom) + except AccessError: + # If user doesn't have access to BOM + # he can't see potential_qty + component_needs = None + + if not component_needs: + # The BoM has no line we can use + product.potential_qty = 0.0 + + else: + # Find the lowest quantity we can make with the stock at hand + components_potential_qty = min( + [self._get_component_qty(component) // need + for component, need in component_needs.items()] + ) + + # Compute with bom quantity + bom_qty = uom_obj._compute_qty_obj( + bom.product_uom, + bom.product_qty, + bom.product_tmpl_id.uom_id + ) + product.potential_qty = bom_qty * components_potential_qty + + def _get_component_qty(self, component): + """ Return the component qty to use based en company settings. + + :type component: product_product + :rtype: float + """ + icp = self.env['ir.config_parameter'] + stock_available_mrp_based_on = icp.get_param( + 'stock_available_mrp_based_on', 'qty_available' + ) + + return component[stock_available_mrp_based_on] + + def _get_components_needs(self, product, bom): + """ Return the needed qty of each compoments in the *bom* of *product*. + + :type product: product_product + :type bom: mrp_bom + :rtype: collections.Counter + """ + bom_obj = self.env['mrp.bom'] + uom_obj = self.env['product.uom'] + product_obj = self.env['product.product'] + + needs = Counter() + for bom_component in bom_obj._bom_explode(bom, product, 1.0)[0]: + product_uom = uom_obj.browse(bom_component['product_uom']) + component = product_obj.browse(bom_component['product_id']) + + component_qty = uom_obj._compute_qty_obj( + product_uom, + bom_component['product_qty'], + component.uom_id, + ) + needs += Counter( + {component: component_qty} + ) + + return needs + + def _get_component_ids(self): + """ Compute component_ids by getting all the components for + this product. + """ + bom_obj = self.env['mrp.bom'] + + bom_id = bom_obj._bom_find(product_id=self.id) + if bom_id: + bom = bom_obj.browse(bom_id) + for bom_component in bom_obj._bom_explode(bom, self, 1.0)[0]: + self.component_ids |= self.browse(bom_component['product_id']) diff --git a/stock_available_mrp/models/product_template.py b/stock_available_mrp/models/product_template.py new file mode 100644 index 000000000..f87ef6901 --- /dev/null +++ b/stock_available_mrp/models/product_template.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# © 2014 Numérigraphe SARL +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from openerp import models, fields, api +from openerp.addons import decimal_precision as dp + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + potential_qty = fields.Float( + compute='_get_potential_qty', + type='float', + digits_compute=dp.get_precision('Product Unit of Measure'), + string='Potential', + help="Quantity of this Product that could be produced using " + "the materials already at hand. " + "If the product has several variants, this will be the biggest " + "quantity that can be made for a any single variant.") + + @api.multi + @api.depends('potential_qty') + def _immediately_usable_qty(self): + """Add the potential quantity to the quantity available to promise. + + This is the same implementation as for variants.""" + super(ProductTemplate, self)._immediately_usable_qty() + for tmpl in self: + tmpl.immediately_usable_qty += tmpl.potential_qty + + @api.multi + @api.depends('product_variant_ids.potential_qty') + def _get_potential_qty(self): + """Compute the potential as the max of all the variants's potential. + + We can't add the potential of variants: if they share components we + may not be able to make all the variants. + So we set the arbitrary rule that we can promise up to the biggest + variant's potential. + """ + for tmpl in self: + if not tmpl.product_variant_ids: + continue + tmpl.potential_qty = max( + [v.potential_qty for v in tmpl.product_variant_ids]) diff --git a/stock_available_mrp/product.py b/stock_available_mrp/product.py deleted file mode 100644 index f36b86a5c..000000000 --- a/stock_available_mrp/product.py +++ /dev/null @@ -1,126 +0,0 @@ -# -*- coding: utf-8 -*- -############################################################################## -# -# This module is copyright (C) 2014 Numérigraphe SARL. All Rights Reserved. -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU Affero General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU Affero General Public License for more details. -# -# You should have received a copy of the GNU Affero General Public License -# along with this program. If not, see . -# -############################################################################## - -from openerp import SUPERUSER_ID -from openerp.osv import orm, fields -import openerp.addons.decimal_precision as dp - - -class product_product(orm.Model): - """Add the computation for the stock available to promise""" - _inherit = 'product.product' - - def _product_available(self, cr, uid, ids, field_names=None, arg=False, - context=None): - """Quantity available to promise based on components at hand.""" - # Compute the core quantities - res = super(product_product, self)._product_available( - cr, uid, ids, field_names=field_names, arg=arg, context=context) - # If we didn't get a field_names list, there's nothing to do - if field_names is None: - return res - - if context is None: - context = {} - # Prepare an alternative context without 'uom', to avoid cross-category - # conversions when reading the available stock of components - if 'uom' in context: - context_wo_uom = context.copy() - del context_wo_uom['uom'] - else: - context_wo_uom = context - - # Compute the production capacity - if any([f in field_names - for f in ['potential_qty', 'immediately_usable_qty']]): - # Compute the potential qty from BoMs with components available - bom_obj = self.pool['mrp.bom'] - to_uom = 'uom' in context and self.pool['product.uom'].browse( - cr, SUPERUSER_ID, context['uom'], context=context) - - for product in self.browse(cr, uid, ids, context=context): - # _bom_find() returns a single BoM id. - # We will not check any other BoM for this product - bom_id = bom_obj._bom_find(cr, SUPERUSER_ID, product.id, - product.uom_id.id) - if bom_id: - min_qty = self._compute_potential_qty_from_bom( - cr, uid, bom_id, to_uom or product.uom_id, - context=context) - - if 'potential_qty' in field_names: - res[product.id]['potential_qty'] += min_qty - if 'immediately_usable_qty' in field_names: - res[product.id]['immediately_usable_qty'] += min_qty - - return res - - def _compute_potential_qty_from_bom(self, cr, uid, bom_id, to_uom, - context=None): - """Compute the potential qty from BoMs with components available""" - bom_obj = self.pool['mrp.bom'] - uom_obj = self.pool['product.uom'] - if context is None: - context = {} - if 'uom' in context: - context_wo_uom = context.copy() - del context_wo_uom['uom'] - else: - context_wo_uom = context - min_qty = False - # Browse ignoring the UoM context to avoid cross-category conversions - bom = bom_obj.browse( - cr, uid, [bom_id], context=context_wo_uom)[0] - - # store id of final product uom - - for component in bom.bom_lines: - # qty available in BOM line's UoM - # XXX use context['uom'] instead? - stock_component_qty = uom_obj._compute_qty_obj( - cr, uid, - component.product_id.uom_id, - component.product_id.virtual_available, - component.product_uom) - # qty we can produce with this component, in the BoM's UoM - bom_uom_qty = (stock_component_qty // component.product_qty - ) * bom.product_qty - # Convert back to the reporting default UoM - stock_product_uom_qty = uom_obj._compute_qty_obj( - cr, uid, bom.product_uom, bom_uom_qty, - to_uom) - if min_qty is False: - min_qty = stock_product_uom_qty - elif stock_product_uom_qty < min_qty: - min_qty = stock_product_uom_qty - if min_qty < 0.0: - min_qty = 0.0 - return min_qty - - _columns = { - 'potential_qty': fields.function( - _product_available, method=True, multi='qty_available', - type='float', - digits_compute=dp.get_precision('Product Unit of Measure'), - string='Potential', - help="Quantity of this Product that could be produced using " - "the materials already at hand, following a single level " - "of the Bills of Materials."), - } diff --git a/stock_available_mrp/product_view.xml b/stock_available_mrp/product_view.xml deleted file mode 100644 index 707df74ff..000000000 --- a/stock_available_mrp/product_view.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - product.form.potential_qty - product.product - form - - - - - - - - - - - diff --git a/stock_available_mrp/test/potential_qty.yml b/stock_available_mrp/test/potential_qty.yml deleted file mode 100644 index 352254900..000000000 --- a/stock_available_mrp/test/potential_qty.yml +++ /dev/null @@ -1,70 +0,0 @@ -- Test the computation of the potential quantity on product_product_16, a product with several multi-line BoMs - -- Create a UoM in the category of PCE -- !record {model: product.uom, id: thousand}: - name: Thousand - factor: 0.001 - rounding: 0.0001 - uom_type: bigger - category_id: product.product_uom_categ_unit - -- Receive enough of the first component to run the BoM 1000x, and check that the potential is unchanged -- !python {model: mrp.bom}: | - bom = self.browse( - cr, uid, - self._bom_find( - cr, uid, ref('product.product_product_16'), - ref('product.product_uom_unit'))) - assert len(bom.bom_lines)>1, "The test BoM has a single line, two or more are needed for the test" - initial_qty = bom.product_id.potential_qty - component = bom.bom_lines[0] - assert component.product_uom.category_id.id == ref('product.product_uom_categ_unit'), "The first component's UoM is in the wrong category can't test" - self.pool['stock.move'].create( - cr, uid, - { - 'name': 'Receive first component', - 'product_id': component.product_id.id, - 'product_qty': component.product_qty * 1000.0, - 'product_uom': component.product_id.uom_id.id, - 'location_id': ref('stock.stock_location_suppliers'), - 'location_dest_id': ref('stock.stock_location_stock'), - 'state': 'done', - }) - # Re-read the potential quantity - bom.refresh() - new_qty = bom.product_id.potential_qty - assert new_qty == initial_qty, "Receiving a single component should not change the potential qty (%s instead of %s)" % (new_qty, initial_qty) - -- Receive enough of all the components to run the BoM 1000x and check that the potential is correct -- !python {model: mrp.bom}: | - # Select a BoM for product_product_16 - bom = self.browse( - cr, uid, - self._bom_find( - cr, uid, ref('product.product_product_16'), - ref('product.product_uom_unit'))) - assert len(bom.bom_lines)>1, "The test BoM has a single line, two or more are needed for the test" - initial_qty = bom.product_id.potential_qty - for component in bom.bom_lines: - assert component.product_uom.category_id.id == ref('product.product_uom_categ_unit'), "The first component's UoM is in the wrong category, can't test" - self.pool['stock.move'].create( - cr, uid, - { - 'name': 'Receive all components', - 'product_id': component.product_id.id, - 'product_qty': component.product_qty * 1000.0, - 'product_uom': component.product_id.uom_id.id, - 'location_id': ref('stock.stock_location_suppliers'), - 'location_dest_id': ref('stock.stock_location_stock'), - 'state': 'done', - }) - # Re-read the potential quantity - bom.refresh() - new_qty = bom.product_id.potential_qty - right_qty = initial_qty + bom.product_qty * 1000.0 - assert new_qty == right_qty, "The potential qty is incorrect after receiveing all the components (%s instead of %s)" % (new_qty, right_qty) - # Re-read the potential quantity with a different UoM in the context - new_qty = self.browse( - cr, uid, bom.id, context={'uom': ref('thousand')}).product_id.potential_qty - right_qty = initial_qty / 1000.0 + bom.product_qty - assert abs(new_qty - right_qty) < 0.0001, "The potential qty is incorrect with another UoM in the context (%s instead of %s)" % (new_qty, right_qty) diff --git a/stock_available_mrp/tests/__init__.py b/stock_available_mrp/tests/__init__.py new file mode 100644 index 000000000..e6b40413f --- /dev/null +++ b/stock_available_mrp/tests/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# © 2014 Numérigraphe SARL +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from . import test_potential_qty diff --git a/stock_available_mrp/tests/test_potential_qty.py b/stock_available_mrp/tests/test_potential_qty.py new file mode 100644 index 000000000..896655dae --- /dev/null +++ b/stock_available_mrp/tests/test_potential_qty.py @@ -0,0 +1,540 @@ +# -*- coding: utf-8 -*- +# © 2014 Numérigraphe SARL +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from openerp.tests.common import TransactionCase +from openerp.osv.expression import TRUE_LEAF + + +class TestPotentialQty(TransactionCase): + """Test the potential quantity on a product with a multi-line BoM""" + + def setUp(self): + super(TestPotentialQty, self).setUp() + + 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.setup_demo_data() + + def setup_demo_data(self): + #  An interesting product (multi-line BoM, variants) + self.tmpl = self.browse_ref( + 'product.product_product_4_product_template') + #  First variant + self.var1 = self.browse_ref('product.product_product_4c') + #  Second variant + self.var2 = self.browse_ref('product.product_product_4') + # Components that can be used to make the product + component_ids = [ + # CPUa8 + self.ref('product.product_product_23'), + # RAM-SR2 + self.ref('product.product_product_14'), + # HDD SH-2 replaces RAM-SR2 through our demo phantom BoM + self.ref('product.product_product_18'), + # RAM-SR3 + self.ref('product.product_product_15')] + + # Zero-out the inventory of all variants and components + for component_id in ( + component_ids + [v.id + for v in self.tmpl.product_variant_ids]): + inventory = self.env['stock.inventory'].create( + {'name': 'no components: %s' % component_id, + 'location_id': self.ref('stock.stock_location_locations'), + 'filter': 'product', + 'product_id': component_id}) + inventory.prepare_inventory() + inventory.reset_real_qty() + inventory.action_done() + + #  A product without a BoM + self.product_wo_bom = self.browse_ref('product.product_product_23') + + # 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]} + + # Get the warehouses + self.wh_main = self.browse_ref('stock.warehouse0') + self.wh_ch = self.browse_ref('stock.stock_warehouse_shop0') + + def create_inventory(self, product_id, qty, location_id=None): + if location_id is None: + location_id = self.wh_main.lot_stock_id.id + + inventory = self.env['stock.inventory'].create({ + 'name': 'Test inventory', + 'location_id': location_id, + 'filter': 'partial' + }) + inventory.prepare_inventory() + + self.env['stock.inventory.line'].create({ + 'inventory_id': inventory.id, + 'product_id': product_id, + 'location_id': location_id, + 'product_qty': qty + }) + inventory.action_done() + + def create_simple_bom(self, product, sub_product, + product_qty=1, sub_product_qty=1, + routing_id=False): + bom = self.bom_model.create({ + 'product_tmpl_id': product.product_tmpl_id.id, + 'product_id': product.id, + 'product_qty': product_qty, + 'product_uom': self.ref('product.product_uom_unit'), + 'routing_id': routing_id, + }) + self.bom_line_model.create({ + 'bom_id': bom.id, + 'product_id': sub_product.id, + 'product_qty': sub_product_qty, + 'product_uom': self.ref('product.product_uom_unit'), + }) + + return bom + + def assertPotentialQty(self, record, qty, msg): + record.refresh() + # Check the potential + self.assertEqual(record.potential_qty, qty, msg) + # Check the variation of quantity available for sale + self.assertEqual( + (record.immediately_usable_qty - + self.initial_usable_qties[record.id]), qty, msg) + + def test_potential_qty_no_bom(self): + #  Check the potential when there's no BoM + self.assertPotentialQty( + self.product_wo_bom, 0.0, + "The potential without a BoM should be 0") + + def test_potential_qty_no_bom_for_company(self): + chicago_id = self.ref('stock.res_company_1') + + # Receive 1000x CPUa8s owned by Chicago + inventory = self.env['stock.inventory'].create( + {'name': 'Receive CPUa8', + 'company_id': chicago_id, + 'location_id': self.wh_ch.lot_stock_id.id, + 'filter': 'partial'}) + inventory.prepare_inventory() + self.env['stock.inventory.line'].create( + {'inventory_id': inventory.id, + 'company_id': chicago_id, + 'product_id': self.ref('product.product_product_23'), + 'location_id': self.wh_ch.lot_stock_id.id, + 'product_qty': 1000.0}) + inventory.action_done() + + # Put RAM-SR3 owned by Chicago for 1000x the 1st variant in main WH + inventory = self.env['stock.inventory'].create( + {'name': 'components for 1st variant', + 'company_id': chicago_id, + 'location_id': self.wh_ch.lot_stock_id.id, + 'filter': 'partial'}) + inventory.prepare_inventory() + self.env['stock.inventory.line'].create( + {'inventory_id': inventory.id, + 'company_id': chicago_id, + 'product_id': self.ref('product.product_product_15'), + 'location_id': self.wh_ch.lot_stock_id.id, + 'product_qty': 1000.0}) + inventory.action_done() + self.assertPotentialQty( + self.tmpl, 1000.0, + "Wrong template potential after receiving components") + + test_user = self.env['res.users'].create( + {'name': 'test_demo', + 'login': 'test_demo', + 'company_id': self.ref('base.main_company'), + 'company_ids': [(4, self.ref('base.main_company'))], + 'groups_id': [(4, self.ref('stock.group_stock_user')), + (4, self.ref('mrp.group_mrp_user'))]}) + + bom = self.env['mrp.bom'].search( + [('product_tmpl_id', '=', self.tmpl.id)]) + + test_user_tmpl = self.tmpl.sudo(test_user) + self.assertPotentialQty( + test_user_tmpl, 1000.0, + "Simple user can access to the potential_qty") + + # Set the bom on the main company (visible to members of main company) + # and all products without company (visible to all) + # and the demo user on Chicago (child of main company) + self.env['product.product'].search([ + TRUE_LEAF]).write({'company_id': False}) + test_user.write({'company_id': chicago_id, + 'company_ids': [(4, chicago_id)]}) + bom.company_id = self.ref('base.main_company') + self.assertPotentialQty( + test_user_tmpl, 0, + "The bom should not be visible to non members of the bom's " + "company or company child of the bom's company") + bom.company_id = chicago_id + self.assertPotentialQty( + test_user_tmpl, 1000.0, '') + + def test_group_mrp_missing(self): + test_user = self.env['res.users'].create({ + 'name': 'test_demo', + 'login': 'test_demo', + 'company_id': self.ref('base.main_company'), + 'company_ids': [(4, self.ref('base.main_company'))], + 'groups_id': [(4, self.ref('stock.group_stock_user'))], + }) + + p1 = self.product_model.create({'name': 'Test P1'}) + p2 = self.product_model.create({'name': 'Test P2'}) + + self.create_simple_bom(p1, p2, + routing_id=self.ref('mrp.mrp_routing_0')) + self.create_inventory(p2.id, 1) + + test_user_p1 = p1.sudo(test_user) + # Test user doesn't have access to mrp_routing, can't compute potential + self.assertEqual(0, test_user_p1.potential_qty) + + test_user.groups_id = [(4, self.ref('mrp.group_mrp_user'))] + self.assertEqual(1, test_user_p1.potential_qty) + + def test_potential_qty(self): + for i in [self.tmpl, self.var1, self.var2]: + self.assertPotentialQty( + i, 0.0, + "The potential quantity should start at 0") + + # Receive 1000x CPUa8s + inventory = self.env['stock.inventory'].create( + {'name': 'Receive CPUa8', + 'location_id': self.wh_main.lot_stock_id.id, + 'filter': 'partial'}) + inventory.prepare_inventory() + self.env['stock.inventory.line'].create( + {'inventory_id': inventory.id, + 'product_id': self.ref('product.product_product_23'), + 'location_id': self.wh_main.lot_stock_id.id, + 'product_qty': 1000.0}) + inventory.action_done() + for i in [self.tmpl, self.var1, self.var2]: + self.assertPotentialQty( + i, 0.0, + "Receiving a single component should not change the " + "potential of %s" % i) + + # Receive enough RAM-SR3 to make 1000x the 1st variant in main WH + inventory = self.env['stock.inventory'].create( + {'name': 'components for 1st variant', + 'location_id': self.wh_main.lot_stock_id.id, + 'filter': 'partial'}) + inventory.prepare_inventory() + self.env['stock.inventory.line'].create( + {'inventory_id': inventory.id, + 'product_id': self.ref('product.product_product_15'), + 'location_id': self.wh_main.lot_stock_id.id, + 'product_qty': 1000.0}) + inventory.action_done() + self.assertPotentialQty( + self.tmpl, 1000.0, + "Wrong template potential after receiving components") + self.assertPotentialQty( + self.var1, 1000.0, + "Wrong variant 1 potential after receiving components") + self.assertPotentialQty( + self.var2, 0.0, + "Receiving variant 1's component should not change " + "variant 2's potential") + + # Receive enough components to make 42X the 2nd variant at Chicago + # need 13 dozens of HDD with 50% efficiency to build 42 RAM + # So 313 HDD (with rounding) for 42 RAM + inventory = self.env['stock.inventory'].create( + {'name': 'components for 2nd variant', + 'location_id': self.wh_ch.lot_stock_id.id, + 'filter': 'partial'}) + inventory.prepare_inventory() + self.env['stock.inventory.line'].create( + {'inventory_id': inventory.id, + 'product_id': self.ref('product.product_product_23'), + 'location_id': self.wh_ch.lot_stock_id.id, + 'product_qty': 1000.0}) + self.env['stock.inventory.line'].create( + {'inventory_id': inventory.id, + 'product_id': self.ref('product.product_product_18'), + 'location_id': self.wh_ch.lot_stock_id.id, + 'product_qty': 313.0}) + inventory.action_done() + self.assertPotentialQty( + self.tmpl, 1000.0, + "Wrong template potential after receiving components") + self.assertPotentialQty( + self.var1, 1000.0, + "Receiving variant 2's component should not change " + "variant 1's potential") + self.assertPotentialQty( + self.var2, 42.0, + "Wrong variant 2 potential after receiving components") + # Check by warehouse + self.assertPotentialQty( + self.tmpl.with_context(warehouse=self.wh_main.id), 1000.0, + "Wrong potential quantity in main WH") + self.assertPotentialQty( + self.tmpl.with_context(warehouse=self.wh_ch.id), 42.0, + "Wrong potential quantity in Chicago WH") + # Check by location + self.assertPotentialQty( + self.tmpl.with_context( + location=self.wh_main.lot_stock_id.id), 1000.0, + "Wrong potential quantity in main WH location") + self.assertPotentialQty( + self.tmpl.with_context( + location=self.wh_ch.lot_stock_id.id), + 42.0, + "Wrong potential quantity in Chicago WH location") + + def test_multi_unit_recursive_bom(self): + # Test multi-level and multi-units BOM + + p1 = self.product_model.create({ + 'name': 'Test product with BOM', + }) + + p2 = self.product_model.create({ + 'name': 'Test sub product with BOM', + }) + + p3 = self.product_model.create({ + 'name': 'Test component' + }) + + bom_p1 = self.bom_model.create({ + 'product_tmpl_id': p1.product_tmpl_id.id, + 'product_id': p1.id, + }) + + # 1 dozen of component + self.bom_line_model.create({ + 'bom_id': bom_p1.id, + 'product_id': p3.id, + 'product_qty': 1, + 'product_uom': self.ref('product.product_uom_dozen'), + }) + + # Two p2 which have a bom + self.bom_line_model.create({ + 'bom_id': bom_p1.id, + 'product_id': p2.id, + 'product_qty': 2, + 'product_uom': self.ref('product.product_uom_unit'), + }) + + bom_p2 = self.bom_model.create({ + 'product_tmpl_id': p2.product_tmpl_id.id, + 'product_id': p2.id, + 'type': 'phantom', + }) + + # p2 need 2 unit of component + self.bom_line_model.create({ + 'bom_id': bom_p2.id, + 'product_id': p3.id, + 'product_qty': 2, + 'product_uom': self.ref('product.product_uom_unit'), + }) + + p1.refresh() + + # Need a least 1 dozen + 2 * 2 = 16 units for one P1 + self.assertEqual(0, p1.potential_qty) + + self.create_inventory(p3.id, 1) + + p1.refresh() + self.assertEqual(0, p1.potential_qty) + + self.create_inventory(p3.id, 15) + p1.refresh() + self.assertEqual(0, p1.potential_qty) + + self.create_inventory(p3.id, 16) + p1.refresh() + self.assertEqual(1.0, p1.potential_qty) + + self.create_inventory(p3.id, 25) + p1.refresh() + self.assertEqual(1.0, p1.potential_qty) + + self.create_inventory(p3.id, 32) + p1.refresh() + self.assertEqual(2.0, p1.potential_qty) + + def test_bom_qty_and_efficiency(self): + + p1 = self.product_model.create({ + 'name': 'Test product with BOM', + }) + + p2 = self.product_model.create({ + 'name': 'Test sub product with BOM', + }) + + p3 = self.product_model.create({ + 'name': 'Test component' + }) + + # A bom produce 2 dozen of P1 + bom_p1 = self.bom_model.create({ + 'product_tmpl_id': p1.product_tmpl_id.id, + 'product_id': p1.id, + 'product_qty': 2, + 'product_uom': self.ref('product.product_uom_dozen'), + }) + + # Need 5 p2 for that + self.bom_line_model.create({ + 'bom_id': bom_p1.id, + 'product_id': p2.id, + 'product_qty': 5, + 'product_uom': self.ref('product.product_uom_unit'), + 'product_efficiency': 0.8, + }) + + # Which need 1 dozen of P3 + bom_p2 = self.bom_model.create({ + 'product_tmpl_id': p2.product_tmpl_id.id, + 'product_id': p2.id, + 'type': 'phantom', + }) + self.bom_line_model.create({ + 'bom_id': bom_p2.id, + 'product_id': p3.id, + 'product_qty': 1, + 'product_uom': self.ref('product.product_uom_dozen'), + }) + + p1.refresh() + self.assertEqual(0, p1.potential_qty) + + self.create_inventory(p3.id, 60) + + p1.refresh() + self.assertEqual(0, p1.potential_qty) + + # Need 5 * 1 dozen => 60 + # But 80% lost each dozen, need 3 more by dozen => 60 + 5 *3 => 75 + self.create_inventory(p3.id, 75) + + p1.refresh() + self.assertEqual(24, 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) + imac = self.browse_ref('product.product_product_8') + + # Set on hand qty + self.create_inventory(imac.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, + 'product_uom': self.ref('product.product_uom_unit'), + }) + + # Need 1 iMac for that + self.bom_line_model.create({ + 'bom_id': bom_p1.id, + 'product_id': imac.id, + 'product_qty': 1, + 'product_uom': self.ref('product.product_uom_unit'), + }) + + # 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 + imac_component = self.product_model.create({ + 'name': 'iMac component', + }) + self.create_inventory(imac_component.id, 5) + + imac_bom = self.bom_model.create({ + 'product_tmpl_id': imac.product_tmpl_id.id, + 'product_id': imac.id, + 'product_qty': 1, + 'product_uom': self.ref('product.product_uom_unit'), + 'type': 'phantom', + }) + + # Need 1 imac_component for iMac + self.bom_line_model.create({ + 'bom_id': imac_bom.id, + 'product_id': imac_component.id, + 'product_qty': 1, + 'product_uom': self.ref('product.product_uom_unit'), + }) + + 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 + # Recursive compute is not working + + p1 = self.product_model.create({'name': 'Test P1'}) + p2 = self.product_model.create({'name': 'Test P2'}) + p3 = self.product_model.create({'name': 'Test P3'}) + + self.config.set_param('stock_available_mrp_based_on', + 'immediately_usable_qty') + + # P1 need one P2 + self.create_simple_bom(p1, p2) + # P2 need one P3 + self.create_simple_bom(p2, p3) + + self.create_inventory(p3.id, 3) + + self.product_model.invalidate_cache() + + products = self.product_model.search( + [('id', 'in', [p1.id, p2.id, p3.id])] + ) + + self.assertEqual( + {p1.id: 3.0, p2.id: 3.0, p3.id: 0.0}, + {p.id: p.potential_qty for p in products} + ) diff --git a/stock_available_mrp/views/product_template_view.xml b/stock_available_mrp/views/product_template_view.xml new file mode 100644 index 000000000..446e0c5c3 --- /dev/null +++ b/stock_available_mrp/views/product_template_view.xml @@ -0,0 +1,27 @@ + + + + + + Potential quantity on product form + product.template + form + + + + + + + + + + +