From 9c8a6b7ae487bbba7a58cc12c5cfb143c3103270 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Warnon?= Date: Mon, 22 Oct 2018 09:36:26 +0200 Subject: [PATCH 01/11] [IMP] stock: add stock for demo data: customizable desk & conf. chair Targets commit d3530eb07e24278117ab396ecb86a59deecc312d Purpose ======= - The "Customizable Desk" and "Conference chair" products now have some stock in the initial inventory to avoid the stock warning when adding these products in a SO through the product configurator. --- addons/sale_stock/data/sale_order_demo.xml | 22 ++++++++++++++ addons/stock/data/stock_demo.xml | 35 ++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/addons/sale_stock/data/sale_order_demo.xml b/addons/sale_stock/data/sale_order_demo.xml index b1ce1cb758bca..cf3d29808cac3 100644 --- a/addons/sale_stock/data/sale_order_demo.xml +++ b/addons/sale_stock/data/sale_order_demo.xml @@ -26,5 +26,27 @@ + + Inventory for new Customizable Desks + + + + + + + 65.0 + + + + + + + 70.0 + + + + + + diff --git a/addons/stock/data/stock_demo.xml b/addons/stock/data/stock_demo.xml index c9ee3a0f7322a..d92c6a69c54b4 100644 --- a/addons/stock/data/stock_demo.xml +++ b/addons/stock/data/stock_demo.xml @@ -65,6 +65,41 @@ 26.0 + + + + + 30.0 + + + + + + + 45.0 + + + + + + + 50.0 + + + + + + + 55.0 + + + + + + + 60.0 + + From 63842baa063446bf6f25563f2154697602677284 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Warnon?= Date: Wed, 10 Oct 2018 15:33:05 +0200 Subject: [PATCH 02/11] [IMP] (website_)sale: improve the exclusion mechanism in p.configurator Targets commit d3530eb07e24278117ab396ecb86a59deecc312d References task id 1891970 Purpose ======= - A message will be displayed when hovering a disabled attribute value's input to explain why it's disabled. e.g: Not available with Color: Black This works for both exclusions within the product and exclusions from a reference product. - The exclusions will work in both sides, if "black" excludes "metal", "metal" will also exclude "black". --- addons/product/models/product_template.py | 101 +++++++++++++----- .../sale/controllers/product_configurator.py | 4 +- .../src/js/product_configurator_mixin.js | 68 ++++++++++-- .../sale_product_configurator_templates.xml | 2 +- 4 files changed, 134 insertions(+), 41 deletions(-) diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py index 4ca5dee43a928..e8bef895967f7 100644 --- a/addons/product/models/product_template.py +++ b/addons/product/models/product_template.py @@ -693,13 +693,15 @@ def get_filtered_variants(self, reference_product=None): return self._get_possible_variants(parent_combination) @api.multi - def _get_attribute_exclusions(self, parent_combination=None): + def _get_attribute_exclusions(self, parent_combination=None, parent_name=None): """Return the list of attribute exclusions of a product. :param parent_combination: the combination from which `self` is an optional or accessory product. Indeed exclusions rules on one product can concern another product. :type parent_combination: recordset `product.template.attribute.value` + :param parent_name: the name of the parent product combination. + :type parent_name: str :return: dict of exclusions - exclusions: from this product itself @@ -711,23 +713,47 @@ def _get_attribute_exclusions(self, parent_combination=None): - has_dynamic_attributes: whether there is a dynamic attribute - no_variant_product_template_attribute_value_ids: values that are no_variant + - parent_product_name: the name of the parent product if any, used in the interface + to explain why some combinations are not available. + (e.g: Not available with Customizable Desk (Legs: Steel)) + - mapped_attribute_names: the name of every attribute values based on their id, + used to explain in the interface why that combination is not available + (e.g: Not available with Color: Black) """ self.ensure_one() return { - 'exclusions': self._get_own_attribute_exclusions(), + 'exclusions': self._complete_inverse_exclusions(self._get_own_attribute_exclusions()), 'parent_exclusions': self._get_parent_attribute_exclusions(parent_combination), 'archived_combinations': self._get_archived_combinations(), 'has_dynamic_attributes': self.has_dynamic_attributes(), 'existing_combinations': self._get_existing_combinations(), 'no_variant_product_template_attribute_value_ids': self._get_no_variant_product_template_attribute_values(), + 'parent_product_name': parent_name, + 'mapped_attribute_names': self._get_mapped_attribute_names(parent_combination), } + @api.model + def _complete_inverse_exclusions(self, exclusions): + """Will complete the dictionnary of exclusions with their respective inverse + e.g: Black excludes XL and L + -> XL excludes Black + -> L excludes Black""" + result = dict(exclusions) + for key, value in exclusions.items(): + for exclusion in value: + if exclusion in result and key not in result[exclusion]: + result[exclusion].append(key) + else: + result[exclusion] = [key] + + return result + @api.multi def _get_own_attribute_exclusions(self): """Get exclusions coming from the current template. - Dictionnary, each ptav is a key, and for each of them the value is - an array with the other ptav that they exclude (empty if no exclusion). + Dictionnary, each product template attribute value is a key, and for each of them + the value is an array with the other ptav that they exclude (empty if no exclusion). """ self.ensure_one() product_template_attribute_values = self._get_valid_product_template_attribute_lines().mapped('product_template_value_ids') @@ -745,44 +771,40 @@ def _get_own_attribute_exclusions(self): def _get_parent_attribute_exclusions(self, parent_combination): """Get exclusions coming from the parent combination. - Array, each element is a ptav that is excluded because of the parent. + Dictionnary, each parent's ptav is a key, and for each of them the value is + an array with the other ptav that are excluded because of the parent. """ self.ensure_one() if not parent_combination: - return [] + return {} - # Search for exclusions without attribute value. This means that the template is not - # compatible with the parent combination. If such an exclusion is found, it means that all - # attribute values are excluded. - if parent_combination: - exclusions = self.env['product.template.attribute.exclusion'].search([ - ('product_tmpl_id', '=', self.id), - ('value_ids', '=', False), - ('product_template_attribute_value_id', 'in', parent_combination.ids), - ], limit=1) - if exclusions: - return self.mapped('attribute_line_ids.product_template_value_ids').ids - - return [ - value_id - for filter_line in parent_combination.mapped('exclude_for').filtered( + result = {} + for product_attribute_value in parent_combination: + for filter_line in product_attribute_value.exclude_for.filtered( lambda filter_line: filter_line.product_tmpl_id == self - ) for value_id in filter_line.value_ids.ids - ] + ): + # Some exclusions don't have attribute value. This means that the template is not + # compatible with the parent combination. If such an exclusion is found, it means that all + # attribute values are excluded. + if filter_line.value_ids: + result[product_attribute_value.id] = filter_line.value_ids.ids + else: + result[product_attribute_value.id] = filter_line.product_tmpl_id.mapped('attribute_line_ids.product_template_value_ids').ids + + return result @api.multi def _get_archived_combinations(self): - self.ensure_one() """Get archived combinations. Array, each element is an array with ids of an archived combination. """ + self.ensure_one() return [archived_variant.product_template_attribute_value_ids.ids for archived_variant in self.valid_archived_variant_ids] @api.multi def _get_existing_combinations(self): - self.ensure_one() """Get existing combinations. Needed because when not using dynamic attributes, the combination is @@ -790,6 +812,7 @@ def _get_existing_combinations(self): Array, each element is an array with ids of an existing combination. """ + self.ensure_one() return [variant.product_template_attribute_value_ids.ids for variant in self.valid_existing_variant_ids] @@ -801,6 +824,25 @@ def _get_no_variant_product_template_attribute_values(self): lambda v: v.attribute_id.create_variant == 'no_variant' ).ids + @api.multi + def _get_mapped_attribute_names(self, parent_combination=None): + """ The name of every attribute values based on their id, + used to explain in the interface why that combination is not available + (e.g: Not available with Color: Black). + + It contains both attribute value names from this product and from + the parent combination if provided. + """ + self.ensure_one() + all_product_attribute_values = self._get_valid_product_template_attribute_lines().mapped('product_template_value_ids') + if parent_combination: + all_product_attribute_values |= parent_combination + + return { + attribute_value.id: attribute_value.display_name + for attribute_value in all_product_attribute_values + } + @api.multi def _is_combination_possible(self, combination, parent_combination=None): """ @@ -855,9 +897,12 @@ def _is_combination_possible(self, combination, parent_combination=None): parent_exclusions = self._get_parent_attribute_exclusions(parent_combination) if parent_exclusions: - for exclusion in parent_exclusions: - if exclusion in combination.ids: - return False + # parent_exclusion are mapped by ptav but here we don't need to know + # where the exclusion comes from so we loop directly on the dict values + for exclusions_values in parent_exclusions.values(): + for exclusion in exclusions_values: + if exclusion in combination.ids: + return False filtered_combination = combination._without_no_variant_attributes() archived_combinations = self._get_archived_combinations() diff --git a/addons/sale/controllers/product_configurator.py b/addons/sale/controllers/product_configurator.py index eea3dfe67fd43..8ebcf879ba853 100644 --- a/addons/sale/controllers/product_configurator.py +++ b/addons/sale/controllers/product_configurator.py @@ -69,6 +69,7 @@ def _optional_product_items(self, product_id, pricelist, **kw): 'product': product, # reference_product deprecated, use parent_combination instead 'reference_product': product, + 'parent_name': product.name, 'parent_combination': parent_combination, 'pricelist': pricelist, # to_currency deprecated, get from pricelist or product @@ -106,6 +107,7 @@ def compute_currency(price): 'add_qty': add_qty, # reference_product deprecated, use combination instead 'reference_product': product, + 'parent_name': product.name, 'variant_values': variant_values, 'pricelist': pricelist, # compute_currency deprecated, get from pricelist or product @@ -126,7 +128,7 @@ def _get_attribute_exclusions(self, product, reference_product=None): # Add "no_variant" attribute values' exclusions # They are kept in the context since they are not linked to this product variant parent_combination |= reference_product.env.context.get('no_variant_attribute_values') - return product._get_attribute_exclusions(parent_combination) + return product._get_attribute_exclusions(parent_combination, reference_product.name if reference_product else None) def _get_product_context(self, pricelist=None, **kw): """deprecated, can be removed in master""" diff --git a/addons/sale/static/src/js/product_configurator_mixin.js b/addons/sale/static/src/js/product_configurator_mixin.js index 25cce5c426085..804718142074c 100644 --- a/addons/sale/static/src/js/product_configurator_mixin.js +++ b/addons/sale/static/src/js/product_configurator_mixin.js @@ -365,7 +365,11 @@ var ProductConfiguratorMixin = { .find('ul[data-attribute_exclusions]') .data('attribute_exclusions'); - $parent.find('option, input, label').removeClass('css_not_available'); + $parent + .find('option, input, label') + .removeClass('css_not_available') + .attr('title', '') + .data('excluded-by', ''); var disable = false; @@ -393,20 +397,35 @@ var ProductConfiguratorMixin = { // disable the excluded input (even when not already selected) // to give a visual feedback before click - self._disableInput($parent, excluded_ptav); + self._disableInput( + $parent, + excluded_ptav, + current_ptav, + combinationData.mapped_attribute_names + ); }); } }); } // parent exclusions (tell which attributes are excluded from parent) - _.each(combinationData.parent_exclusions, function (ptav) { - if (isPtavInCombination(ptav, combination)) { - disable = true; - } - // disable the excluded input (even when not already selected) - // to give a visual feedback before click - self._disableInput($parent, ptav); + _.each(combinationData.parent_exclusions, function (exclusions, excluded_by){ + // check that the selected combination is in the parent exclusions + _.each(exclusions, function (ptav) { + if (isPtavInCombination(ptav, combination)) { + disable = true; + } + + // disable the excluded input (even when not already selected) + // to give a visual feedback before click + self._disableInput( + $parent, + ptav, + excluded_by, + combinationData.mapped_attribute_names, + combinationData.parent_product_name + ); + }); }); // archived variants @@ -428,7 +447,7 @@ var ProductConfiguratorMixin = { $parent.find("#add_to_cart").toggleClass('disabled', disable); $parent .parents('.modal') - .find('.o_sale_product_configurator_add') + .find('.o_sale_product_configurator_add, .o_sale_product_configurator_edit') .toggleClass('disabled', disable); }, @@ -445,15 +464,42 @@ var ProductConfiguratorMixin = { * Will disable the input/option that refers to the passed attributeValueId. * This is used for showing the user that some combinations are not available. * + * It will also display a message explaining why the input is not selectable. + * Based on the "excludedBy" and the "productName" params. + * e.g: Not available with Color: Black + * * @private * @param {$.Element} $parent * @param {integer} attributeValueId + * @param {integer} excludedBy The attribute value that excludes this input + * @param {Object} attributeNames A dict containing all the names of the attribute values + * to show a human readable message explaining why the input is disabled. + * @param {string} [productName] The parent product. If provided, it will be appended before + * the name of the attribute value that excludes this input + * e.g: Not available with Customizable Desk (Color: Black) */ - _disableInput: function ($parent, attributeValueId) { + _disableInput: function ($parent, attributeValueId, excludedBy, attributeNames, productName) { var $input = $parent .find('option[value=' + attributeValueId + '], input[value=' + attributeValueId + ']'); $input.addClass('css_not_available'); $input.closest('label').addClass('css_not_available'); + + if (excludedBy && attributeNames) { + var $target = $input.is('option') ? $input : $input.closest('label').add($input); + var excludedByData = []; + if ($target.data('excluded-by')) { + excludedByData = JSON.parse($target.data('excluded-by')); + } + + var excludedByName = attributeNames[excludedBy]; + if (productName) { + excludedByName = productName + ' (' + excludedByName + ')'; + } + excludedByData.push(excludedByName); + + $target.attr('title', _.str.sprintf(_t('Not available with %s'), excludedByData.join(', '))); + $target.data('excluded-by', JSON.stringify(excludedByData)); + } }, /** diff --git a/addons/sale/views/sale_product_configurator_templates.xml b/addons/sale/views/sale_product_configurator_templates.xml index 075a4f1175173..1f0ec29d53452 100644 --- a/addons/sale/views/sale_product_configurator_templates.xml +++ b/addons/sale/views/sale_product_configurator_templates.xml @@ -205,7 +205,7 @@