Merge pull request #4232 from anandpdoshi/variant-smart-selection

Numeric attribute selector, smart selection of variant based on attribute combinations
diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py
index 94c0c62..0f1f0e0 100644
--- a/erpnext/stock/doctype/item/item.py
+++ b/erpnext/stock/doctype/item/item.py
@@ -5,6 +5,7 @@
 import frappe
 import json
 import urllib
+import itertools
 from frappe import msgprint, _
 from frappe.utils import cstr, flt, cint, getdate, now_datetime, formatdate
 from frappe.website.website_generator import WebsiteGenerator
@@ -130,6 +131,8 @@
 
 		self.set_attribute_context(context)
 
+		self.set_disabled_attributes(context)
+
 		context.parents = self.get_parents(context)
 
 		return context
@@ -189,15 +192,63 @@
 			for attr in self.attributes:
 				values = context.attribute_values.setdefault(attr.attribute, [])
 
-				# get list of values defined (for sequence)
-				for attr_value in frappe.db.get_all("Item Attribute Value",
-					fields=["attribute_value"], filters={"parent": attr.attribute}, order_by="idx asc"):
+				if cint(frappe.db.get_value("Item Attribute", attr.attribute, "numeric_values")):
+					for val in sorted(attribute_values_available.get(attr.attribute, []), key=flt):
+						values.append(val)
 
-					if attr_value.attribute_value in attribute_values_available.get(attr.attribute, []):
-						values.append(attr_value.attribute_value)
+				else:
+					# get list of values defined (for sequence)
+					for attr_value in frappe.db.get_all("Item Attribute Value",
+						fields=["attribute_value"], filters={"parent": attr.attribute}, order_by="idx asc"):
+
+						if attr_value.attribute_value in attribute_values_available.get(attr.attribute, []):
+							values.append(attr_value.attribute_value)
 
 			context.variant_info = json.dumps(context.variants)
 
+	def set_disabled_attributes(self, context):
+		"""Disable selection options of attribute combinations that do not result in a variant"""
+		if not self.attributes:
+			return
+
+		context.disabled_attributes = {}
+		attributes = [attr.attribute for attr in self.attributes]
+
+		def find_variant(combination):
+			for variant in context.variants:
+				if len(variant.attributes) < len(attributes):
+					continue
+
+				if "combination" not in variant:
+					ref_combination = []
+
+					for attr in variant.attributes:
+						idx = attributes.index(attr.attribute)
+						ref_combination.insert(idx, attr.attribute_value)
+
+					variant["combination"] = ref_combination
+
+				if not (set(combination) - set(variant["combination"])):
+					# check if the combination is a subset of a variant combination
+					# eg. [Blue, 0.5] is a possible combination if exists [Blue, Large, 0.5]
+					return True
+
+		for i, attr in enumerate(self.attributes):
+			if i==0:
+				continue
+
+			combination_source = []
+
+			# loop through previous attributes
+			for prev_attr in self.attributes[:i]:
+				combination_source.append([context.selected_attributes[prev_attr.attribute]])
+
+			combination_source.append(context.attribute_values[attr.attribute])
+
+			for combination in itertools.product(*combination_source):
+				if not find_variant(combination):
+					context.disabled_attributes.setdefault(attr.attribute, []).append(combination[-1])
+
 	def check_warehouse_is_set_for_stock_item(self):
 		if self.is_stock_item==1 and not self.default_warehouse and frappe.get_all("Warehouse"):
 			frappe.msgprint(_("Default Warehouse is mandatory for stock Item."),
diff --git a/erpnext/templates/generators/item.html b/erpnext/templates/generators/item.html
index 4bd521d..acbcedf 100644
--- a/erpnext/templates/generators/item.html
+++ b/erpnext/templates/generators/item.html
@@ -29,7 +29,8 @@
 				<div class="item-attribute-selectors">
                     {% if has_variants %}
                     {% for d in attributes %}
-                    <div class="item-view-attribute"
+					{% if attribute_values[d.attribute] -%}
+                    <div class="item-view-attribute {% if (attribute_values[d.attribute] | len)==1 -%} hidden {%- endif %}"
                             style="margin-bottom: 10px;">
                         <h6 class="text-muted">{{ _(d.attribute) }}</h6>
                         <select class="form-control"
@@ -37,12 +38,17 @@
                             data-attribute="{{ d.attribute }}">
 						{% for value in attribute_values[d.attribute] %}
                         <option value="{{ value }}"
-							{% if selected_attributes and selected_attributes[d.attribute]==value -%} selected {%- endif %}>
+						{% if selected_attributes and selected_attributes[d.attribute]==value -%}
+							selected
+						{%- elif disabled_attributes and value in disabled_attributes.get(d.attribute, []) -%}
+							disabled
+						{%- endif %}>
 							{{ _(value) }}
 						</option>
                         {% endfor %}
                         </select>
                     </div>
+					{%- endif %}
                     {% endfor %}
                     {% endif %}
 				</div>
diff --git a/erpnext/templates/includes/product_page.js b/erpnext/templates/includes/product_page.js
index 2345de4..e28f351 100644
--- a/erpnext/templates/includes/product_page.js
+++ b/erpnext/templates/includes/product_page.js
@@ -64,8 +64,23 @@
 		});
 	});
 
-	$("[itemscope] .item-view-attribute select").on("change", function() {
-		var item_code = encodeURIComponent(get_item_code());
+	$("[itemscope] .item-view-attribute .form-control").on("change", function() {
+		try {
+			var item_code = encodeURIComponent(get_item_code());
+		} catch(e) {
+			// unable to find variant
+			// then chose the closest available one
+
+			var attribute = $(this).attr("data-attribute");
+			var attribute_value = $(this).val()
+			var item_code = update_attribute_selectors(attribute, attribute_value);
+
+			if (!item_code) {
+				msgprint(__("Please select some other value for {0}", [attribute]))
+				throw e;
+			}
+		}
+
 		if (window.location.search.indexOf(item_code)!==-1) {
 			return;
 		}
@@ -83,10 +98,8 @@
 
 function get_item_code() {
 	if(window.variant_info) {
-		attributes = {};
-		$('[itemscope]').find(".item-view-attribute select").each(function() {
-			attributes[$(this).attr('data-attribute')] = $(this).val();
-		});
+		var attributes = get_selected_attributes();
+
 		for(var i in variant_info) {
 			var variant = variant_info[i];
 			var match = true;
@@ -106,3 +119,53 @@
 		return item_code;
 	}
 }
+
+function update_attribute_selectors(selected_attribute, selected_attribute_value) {
+	// find the closest match keeping the selected attribute in focus and get the item code
+
+	var attributes = get_selected_attributes();
+
+	var previous_match_score = 0;
+	var matched;
+	for(var i in variant_info) {
+		var variant = variant_info[i];
+		var match_score = 0;
+		var has_selected_attribute = false;
+
+		console.log(variant);
+
+		for(var j in variant.attributes) {
+			if(attributes[variant.attributes[j].attribute]===variant.attributes[j].attribute_value) {
+				match_score = match_score + 1;
+
+				if (variant.attributes[j].attribute==selected_attribute && variant.attributes[j].attribute_value==selected_attribute_value) {
+					has_selected_attribute = true;
+				}
+			}
+		}
+
+		if (has_selected_attribute && (match_score > previous_match_score)) {
+			previous_match_score = match_score;
+			matched = variant;
+		}
+	}
+
+	if (matched) {
+		for (var j in matched.attributes) {
+			var attr = matched.attributes[j];
+			$('[itemscope]')
+				.find(repl('.item-view-attribute .form-control[data-attribute="%(attribute)s"]', attr))
+				.val(attr.attribute_value);
+		}
+
+		return matched.name;
+	}
+}
+
+function get_selected_attributes() {
+	var attributes = {};
+	$('[itemscope]').find(".item-view-attribute .form-control").each(function() {
+		attributes[$(this).attr('data-attribute')] = $(this).val();
+	});
+	return attributes;
+}