Merge branch 'develop' into alternative-items-quotation
diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py
index 8c403aa..1edd7bf 100644
--- a/erpnext/controllers/taxes_and_totals.py
+++ b/erpnext/controllers/taxes_and_totals.py
@@ -24,11 +24,19 @@
 	def __init__(self, doc: Document):
 		self.doc = doc
 		frappe.flags.round_off_applicable_accounts = []
+
+		self._items = self.filter_rows() if self.doc.doctype == "Quotation" else self.doc.get("items")
+
 		get_round_off_applicable_accounts(self.doc.company, frappe.flags.round_off_applicable_accounts)
 		self.calculate()
 
+	def filter_rows(self):
+		"""Exclude rows, that do not fulfill the filter criteria, from totals computation."""
+		items = list(filter(lambda item: not item.get("is_alternative"), self.doc.get("items")))
+		return items
+
 	def calculate(self):
-		if not len(self.doc.get("items")):
+		if not len(self._items):
 			return
 
 		self.discount_amount_applied = False
@@ -70,7 +78,7 @@
 		if hasattr(self.doc, "tax_withholding_net_total"):
 			sum_net_amount = 0
 			sum_base_net_amount = 0
-			for item in self.doc.get("items"):
+			for item in self._items:
 				if hasattr(item, "apply_tds") and item.apply_tds:
 					sum_net_amount += item.net_amount
 					sum_base_net_amount += item.base_net_amount
@@ -79,7 +87,7 @@
 			self.doc.base_tax_withholding_net_total = sum_base_net_amount
 
 	def validate_item_tax_template(self):
-		for item in self.doc.get("items"):
+		for item in self._items:
 			if item.item_code and item.get("item_tax_template"):
 				item_doc = frappe.get_cached_doc("Item", item.item_code)
 				args = {
@@ -137,7 +145,7 @@
 			return
 
 		if not self.discount_amount_applied:
-			for item in self.doc.get("items"):
+			for item in self._items:
 				self.doc.round_floats_in(item)
 
 				if item.discount_percentage == 100:
@@ -236,7 +244,7 @@
 		if not any(cint(tax.included_in_print_rate) for tax in self.doc.get("taxes")):
 			return
 
-		for item in self.doc.get("items"):
+		for item in self._items:
 			item_tax_map = self._load_item_tax_rate(item.item_tax_rate)
 			cumulated_tax_fraction = 0
 			total_inclusive_tax_amount_per_qty = 0
@@ -317,7 +325,7 @@
 			self.doc.total
 		) = self.doc.base_total = self.doc.net_total = self.doc.base_net_total = 0.0
 
-		for item in self.doc.get("items"):
+		for item in self._items:
 			self.doc.total += item.amount
 			self.doc.total_qty += item.qty
 			self.doc.base_total += item.base_amount
@@ -354,7 +362,7 @@
 			]
 		)
 
-		for n, item in enumerate(self.doc.get("items")):
+		for n, item in enumerate(self._items):
 			item_tax_map = self._load_item_tax_rate(item.item_tax_rate)
 			for i, tax in enumerate(self.doc.get("taxes")):
 				# tax_amount represents the amount of tax for the current step
@@ -363,7 +371,7 @@
 				# Adjust divisional loss to the last item
 				if tax.charge_type == "Actual":
 					actual_tax_dict[tax.idx] -= current_tax_amount
-					if n == len(self.doc.get("items")) - 1:
+					if n == len(self._items) - 1:
 						current_tax_amount += actual_tax_dict[tax.idx]
 
 				# accumulate tax amount into tax.tax_amount
@@ -391,7 +399,7 @@
 					)
 
 				# set precision in the last item iteration
-				if n == len(self.doc.get("items")) - 1:
+				if n == len(self._items) - 1:
 					self.round_off_totals(tax)
 					self._set_in_company_currency(tax, ["tax_amount", "tax_amount_after_discount_amount"])
 
@@ -570,7 +578,7 @@
 	def calculate_total_net_weight(self):
 		if self.doc.meta.get_field("total_net_weight"):
 			self.doc.total_net_weight = 0.0
-			for d in self.doc.items:
+			for d in self._items:
 				if d.total_weight:
 					self.doc.total_net_weight += d.total_weight
 
@@ -630,7 +638,7 @@
 
 			if total_for_discount_amount:
 				# calculate item amount after Discount Amount
-				for i, item in enumerate(self.doc.get("items")):
+				for i, item in enumerate(self._items):
 					distributed_amount = (
 						flt(self.doc.discount_amount) * item.net_amount / total_for_discount_amount
 					)
@@ -643,7 +651,7 @@
 						self.doc.apply_discount_on == "Net Total"
 						or not taxes
 						or total_for_discount_amount == self.doc.net_total
-					) and i == len(self.doc.get("items")) - 1:
+					) and i == len(self._items) - 1:
 						discount_amount_loss = flt(
 							self.doc.net_total - net_total - self.doc.discount_amount, self.doc.precision("net_total")
 						)
diff --git a/erpnext/public/js/controllers/taxes_and_totals.js b/erpnext/public/js/controllers/taxes_and_totals.js
index a87c3ec..623b338 100644
--- a/erpnext/public/js/controllers/taxes_and_totals.js
+++ b/erpnext/public/js/controllers/taxes_and_totals.js
@@ -91,6 +91,9 @@
 	}
 
 	_calculate_taxes_and_totals() {
+		const is_quotation = this.frm.doc.doctype == "Quotation";
+		this.frm.doc._items = is_quotation ? this.filtered_items() : this.frm.doc.items;
+
 		this.validate_conversion_rate();
 		this.calculate_item_values();
 		this.initialize_taxes();
@@ -122,7 +125,7 @@
 	calculate_item_values() {
 		var me = this;
 		if (!this.discount_amount_applied) {
-			for (const item of this.frm.doc.items || []) {
+			for (const item of this.frm.doc._items || []) {
 				frappe.model.round_floats_in(item);
 				item.net_rate = item.rate;
 				item.qty = item.qty === undefined ? (me.frm.doc.is_return ? -1 : 1) : item.qty;
@@ -206,7 +209,7 @@
 		});
 		if(has_inclusive_tax==false) return;
 
-		$.each(me.frm.doc["items"] || [], function(n, item) {
+		$.each(me.frm.doc._items || [], function(n, item) {
 			var item_tax_map = me._load_item_tax_rate(item.item_tax_rate);
 			var cumulated_tax_fraction = 0.0;
 			var total_inclusive_tax_amount_per_qty = 0;
@@ -277,7 +280,7 @@
 		var me = this;
 		this.frm.doc.total_qty = this.frm.doc.total = this.frm.doc.base_total = this.frm.doc.net_total = this.frm.doc.base_net_total = 0.0;
 
-		$.each(this.frm.doc["items"] || [], function(i, item) {
+		$.each(this.frm.doc._items || [], function(i, item) {
 			me.frm.doc.total += item.amount;
 			me.frm.doc.total_qty += item.qty;
 			me.frm.doc.base_total += item.base_amount;
@@ -330,7 +333,7 @@
 			}
 		});
 
-		$.each(this.frm.doc["items"] || [], function(n, item) {
+		$.each(this.frm.doc._items || [], function(n, item) {
 			var item_tax_map = me._load_item_tax_rate(item.item_tax_rate);
 			$.each(me.frm.doc["taxes"] || [], function(i, tax) {
 				// tax_amount represents the amount of tax for the current step
@@ -339,7 +342,7 @@
 				// Adjust divisional loss to the last item
 				if (tax.charge_type == "Actual") {
 					actual_tax_dict[tax.idx] -= current_tax_amount;
-					if (n == me.frm.doc["items"].length - 1) {
+					if (n == me.frm.doc._items.length - 1) {
 						current_tax_amount += actual_tax_dict[tax.idx];
 					}
 				}
@@ -376,7 +379,7 @@
 				}
 
 				// set precision in the last item iteration
-				if (n == me.frm.doc["items"].length - 1) {
+				if (n == me.frm.doc._items.length - 1) {
 					me.round_off_totals(tax);
 					me.set_in_company_currency(tax,
 						["tax_amount", "tax_amount_after_discount_amount"]);
@@ -599,10 +602,11 @@
 
 	_cleanup() {
 		this.frm.doc.base_in_words = this.frm.doc.in_words = "";
+		let items = this.frm.doc._items;
 
-		if(this.frm.doc["items"] && this.frm.doc["items"].length) {
-			if(!frappe.meta.get_docfield(this.frm.doc["items"][0].doctype, "item_tax_amount", this.frm.doctype)) {
-				$.each(this.frm.doc["items"] || [], function(i, item) {
+		if(items && items.length) {
+			if(!frappe.meta.get_docfield(items[0].doctype, "item_tax_amount", this.frm.doctype)) {
+				$.each(items || [], function(i, item) {
 					delete item["item_tax_amount"];
 				});
 			}
@@ -655,7 +659,7 @@
 			var net_total = 0;
 			// calculate item amount after Discount Amount
 			if (total_for_discount_amount) {
-				$.each(this.frm.doc["items"] || [], function(i, item) {
+				$.each(this.frm.doc._items || [], function(i, item) {
 					distributed_amount = flt(me.frm.doc.discount_amount) * item.net_amount / total_for_discount_amount;
 					item.net_amount = flt(item.net_amount - distributed_amount,
 						precision("base_amount", item));
@@ -663,7 +667,7 @@
 
 					// discount amount rounding loss adjustment if no taxes
 					if ((!(me.frm.doc.taxes || []).length || total_for_discount_amount==me.frm.doc.net_total || (me.frm.doc.apply_discount_on == "Net Total"))
-							&& i == (me.frm.doc.items || []).length - 1) {
+							&& i == (me.frm.doc._items || []).length - 1) {
 						var discount_amount_loss = flt(me.frm.doc.net_total - net_total
 							- me.frm.doc.discount_amount, precision("net_total"));
 						item.net_amount = flt(item.net_amount + discount_amount_loss,
@@ -892,4 +896,8 @@
 		}
 
 	}
+
+	filtered_items() {
+		return this.frm.doc.items.filter(item => !item["is_alternative"]);
+	}
 };
diff --git a/erpnext/selling/doctype/quotation/quotation.js b/erpnext/selling/doctype/quotation/quotation.js
index b348bd3..81ef44d 100644
--- a/erpnext/selling/doctype/quotation/quotation.js
+++ b/erpnext/selling/doctype/quotation/quotation.js
@@ -90,7 +90,7 @@
 				|| frappe.datetime.get_diff(doc.valid_till, frappe.datetime.get_today()) >= 0) {
 					this.frm.add_custom_button(
 						__("Sales Order"),
-						this.frm.cscript["Make Sales Order"],
+						() => this.make_sales_order(),
 						__("Create")
 					);
 				}
@@ -145,6 +145,20 @@
 
 	}
 
+	make_sales_order() {
+		var me = this;
+
+		let has_alternative_item = this.frm.doc.items.some((item) => item.is_alternative);
+		if (has_alternative_item) {
+			this.show_alternative_items_dialog();
+		} else {
+			frappe.model.open_mapped_doc({
+				method: "erpnext.selling.doctype.quotation.quotation.make_sales_order",
+				frm: me.frm
+			});
+		}
+	}
+
 	set_dynamic_field_label(){
 		if (this.frm.doc.quotation_to == "Customer")
 		{
@@ -220,17 +234,111 @@
 			}
 		})
 	}
+
+	show_alternative_items_dialog() {
+		let me = this;
+
+		const table_fields = [
+		{
+			fieldtype:"Data",
+			fieldname:"name",
+			label: __("Name"),
+			read_only: 1,
+		},
+		{
+			fieldtype:"Link",
+			fieldname:"item_code",
+			options: "Item",
+			label: __("Item Code"),
+			read_only: 1,
+			in_list_view: 1,
+			columns: 2,
+			formatter: (value, df, options, doc) => {
+				return doc.is_alternative ? `<span class="indicator yellow">${value}</span>` : value;
+			}
+		},
+		{
+			fieldtype:"Data",
+			fieldname:"description",
+			label: __("Description"),
+			in_list_view: 1,
+			read_only: 1,
+		},
+		{
+			fieldtype:"Currency",
+			fieldname:"amount",
+			label: __("Amount"),
+			options: "currency",
+			in_list_view: 1,
+			read_only: 1,
+		},
+		{
+			fieldtype:"Check",
+			fieldname:"is_alternative",
+			label: __("Is Alternative"),
+			read_only: 1,
+		}];
+
+
+		this.data = this.frm.doc.items.filter(
+			(item) => item.is_alternative || item.has_alternative_item
+		).map((item) => {
+			return {
+				"name": item.name,
+				"item_code": item.item_code,
+				"description": item.description,
+				"amount": item.amount,
+				"is_alternative": item.is_alternative,
+			}
+		});
+
+		const dialog = new frappe.ui.Dialog({
+			title: __("Select Alternative Items for Sales Order"),
+			fields: [
+				{
+					fieldname: "info",
+					fieldtype: "HTML",
+					read_only: 1
+				},
+				{
+					fieldname: "alternative_items",
+					fieldtype: "Table",
+					cannot_add_rows: true,
+					in_place_edit: true,
+					reqd: 1,
+					data: this.data,
+					description: __("Select an item from each set to be used in the Sales Order."),
+					get_data: () => {
+						return this.data;
+					},
+					fields: table_fields
+				},
+			],
+			primary_action: function() {
+				frappe.model.open_mapped_doc({
+					method: "erpnext.selling.doctype.quotation.quotation.make_sales_order",
+					frm: me.frm,
+					args: {
+						selected_items: dialog.fields_dict.alternative_items.grid.get_selected_children()
+					}
+				});
+				dialog.hide();
+			},
+			primary_action_label: __('Continue')
+		});
+
+		dialog.fields_dict.info.$wrapper.html(
+			`<p class="small text-muted">
+				<span class="indicator yellow"></span>
+				Alternative Items
+			</p>`
+		)
+		dialog.show();
+	}
 };
 
 cur_frm.script_manager.make(erpnext.selling.QuotationController);
 
-cur_frm.cscript['Make Sales Order'] = function() {
-	frappe.model.open_mapped_doc({
-		method: "erpnext.selling.doctype.quotation.quotation.make_sales_order",
-		frm: cur_frm
-	})
-}
-
 frappe.ui.form.on("Quotation Item", "items_on_form_rendered", "packed_items_on_form_rendered", function(frm, cdt, cdn) {
 	// enable tax_amount field if Actual
 })
diff --git a/erpnext/selling/doctype/quotation/quotation.py b/erpnext/selling/doctype/quotation/quotation.py
index 063813b..fc66db2 100644
--- a/erpnext/selling/doctype/quotation/quotation.py
+++ b/erpnext/selling/doctype/quotation/quotation.py
@@ -35,6 +35,9 @@
 
 		make_packing_list(self)
 
+	def before_submit(self):
+		self.set_has_alternative_item()
+
 	def validate_valid_till(self):
 		if self.valid_till and getdate(self.valid_till) < getdate(self.transaction_date):
 			frappe.throw(_("Valid till date cannot be before transaction date"))
@@ -59,7 +62,18 @@
 					title=_("Unpublished Item"),
 				)
 
+	def set_has_alternative_item(self):
+		"""Mark 'Has Alternative Item' for rows."""
+		if not any(row.is_alternative for row in self.get("items")):
+			return
+
+		items_with_alternatives = self.get_rows_with_alternatives()
+		for row in self.get("items"):
+			if not row.is_alternative and row.name in items_with_alternatives:
+				row.has_alternative_item = 1
+
 	def get_ordered_status(self):
+		status = "Open"
 		ordered_items = frappe._dict(
 			frappe.db.get_all(
 				"Sales Order Item",
@@ -70,16 +84,40 @@
 			)
 		)
 
-		status = "Open"
-		if ordered_items:
+		if not ordered_items:
+			return status
+
+		has_alternatives = any(row.is_alternative for row in self.get("items"))
+		self._items = self.get_valid_items() if has_alternatives else self.get("items")
+
+		if any(row.qty > ordered_items.get(row.item_code, 0.0) for row in self._items):
+			status = "Partially Ordered"
+		else:
 			status = "Ordered"
 
-			for item in self.get("items"):
-				if item.qty > ordered_items.get(item.item_code, 0.0):
-					status = "Partially Ordered"
-
 		return status
 
+	def get_valid_items(self):
+		"""
+		Filters out items in an alternatives set that were not ordered.
+		"""
+
+		def is_in_sales_order(row):
+			in_sales_order = bool(
+				frappe.db.exists(
+					"Sales Order Item", {"quotation_item": row.name, "item_code": row.item_code, "docstatus": 1}
+				)
+			)
+			return in_sales_order
+
+		def can_map(row) -> bool:
+			if row.is_alternative or row.has_alternative_item:
+				return is_in_sales_order(row)
+
+			return True
+
+		return list(filter(can_map, self.get("items")))
+
 	def is_fully_ordered(self):
 		return self.get_ordered_status() == "Ordered"
 
@@ -176,6 +214,22 @@
 	def on_recurring(self, reference_doc, auto_repeat_doc):
 		self.valid_till = None
 
+	def get_rows_with_alternatives(self):
+		rows_with_alternatives = []
+		table_length = len(self.get("items"))
+
+		for idx, row in enumerate(self.get("items")):
+			if row.is_alternative:
+				continue
+
+			if idx == (table_length - 1):
+				break
+
+			if self.get("items")[idx + 1].is_alternative:
+				rows_with_alternatives.append(row.name)
+
+		return rows_with_alternatives
+
 
 def get_list_context(context=None):
 	from erpnext.controllers.website_list_for_contact import get_list_context
@@ -221,6 +275,8 @@
 		)
 	)
 
+	selected_rows = [x.get("name") for x in frappe.flags.get("args", {}).get("selected_items", [])]
+
 	def set_missing_values(source, target):
 		if customer:
 			target.customer = customer.name
@@ -244,6 +300,24 @@
 			target.blanket_order = obj.blanket_order
 			target.blanket_order_rate = obj.blanket_order_rate
 
+	def can_map_row(item) -> bool:
+		"""
+		Row mapping from Quotation to Sales order:
+		1. If no selections, map all non-alternative rows (that sum up to the grand total)
+		2. If selections: Is Alternative Item/Has Alternative Item: Map if selected and adequate qty
+		3. If selections: Simple row: Map if adequate qty
+		"""
+		has_qty = item.qty > 0
+
+		if not selected_rows:
+			return not item.is_alternative
+
+		if selected_rows and (item.is_alternative or item.has_alternative_item):
+			return (item.name in selected_rows) and has_qty
+
+		# Simple row
+		return has_qty
+
 	doclist = get_mapped_doc(
 		"Quotation",
 		source_name,
@@ -253,7 +327,7 @@
 				"doctype": "Sales Order Item",
 				"field_map": {"parent": "prevdoc_docname", "name": "quotation_item"},
 				"postprocess": update_item,
-				"condition": lambda doc: doc.qty > 0,
+				"condition": can_map_row,
 			},
 			"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True},
 			"Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
@@ -322,7 +396,11 @@
 		source_name,
 		{
 			"Quotation": {"doctype": "Sales Invoice", "validation": {"docstatus": ["=", 1]}},
-			"Quotation Item": {"doctype": "Sales Invoice Item", "postprocess": update_item},
+			"Quotation Item": {
+				"doctype": "Sales Invoice Item",
+				"postprocess": update_item,
+				"condition": lambda row: not row.is_alternative,
+			},
 			"Sales Taxes and Charges": {"doctype": "Sales Taxes and Charges", "add_if_empty": True},
 			"Sales Team": {"doctype": "Sales Team", "add_if_empty": True},
 		},
diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py
index cdf5f5d..67f6518 100644
--- a/erpnext/selling/doctype/quotation/test_quotation.py
+++ b/erpnext/selling/doctype/quotation/test_quotation.py
@@ -457,6 +457,139 @@
 			expected_index = id + 1
 			self.assertEqual(item.idx, expected_index)
 
+	def test_alternative_items_with_stock_items(self):
+		"""
+		Check if taxes & totals considers only non-alternative items with:
+		- One set of non-alternative & alternative items [first 3 rows]
+		- One simple stock item
+		"""
+		from erpnext.stock.doctype.item.test_item import make_item
+
+		item_list = []
+		stock_items = {
+			"_Test Simple Item 1": 100,
+			"_Test Alt 1": 120,
+			"_Test Alt 2": 110,
+			"_Test Simple Item 2": 200,
+		}
+
+		for item, rate in stock_items.items():
+			make_item(item, {"is_stock_item": 1})
+			item_list.append(
+				{
+					"item_code": item,
+					"qty": 1,
+					"rate": rate,
+					"is_alternative": bool("Alt" in item),
+				}
+			)
+
+		quotation = make_quotation(item_list=item_list, do_not_submit=1)
+		quotation.append(
+			"taxes",
+			{
+				"account_head": "_Test Account VAT - _TC",
+				"charge_type": "On Net Total",
+				"cost_center": "_Test Cost Center - _TC",
+				"description": "VAT",
+				"doctype": "Sales Taxes and Charges",
+				"rate": 10,
+			},
+		)
+		quotation.submit()
+
+		self.assertEqual(quotation.net_total, 300)
+		self.assertEqual(quotation.grand_total, 330)
+
+	def test_alternative_items_with_service_items(self):
+		"""
+		Check if taxes & totals considers only non-alternative items with:
+		- One set of non-alternative & alternative service items [first 3 rows]
+		- One simple non-alternative service item
+		All having the same item code and unique item name/description due to
+		dynamic services
+		"""
+		from erpnext.stock.doctype.item.test_item import make_item
+
+		item_list = []
+		service_items = {
+			"Tiling with Standard Tiles": 100,
+			"Alt Tiling with Durable Tiles": 150,
+			"Alt Tiling with Premium Tiles": 180,
+			"False Ceiling with Material #234": 190,
+		}
+
+		make_item("_Test Dynamic Service Item", {"is_stock_item": 0})
+
+		for name, rate in service_items.items():
+			item_list.append(
+				{
+					"item_code": "_Test Dynamic Service Item",
+					"item_name": name,
+					"description": name,
+					"qty": 1,
+					"rate": rate,
+					"is_alternative": bool("Alt" in name),
+				}
+			)
+
+		quotation = make_quotation(item_list=item_list, do_not_submit=1)
+		quotation.append(
+			"taxes",
+			{
+				"account_head": "_Test Account VAT - _TC",
+				"charge_type": "On Net Total",
+				"cost_center": "_Test Cost Center - _TC",
+				"description": "VAT",
+				"doctype": "Sales Taxes and Charges",
+				"rate": 10,
+			},
+		)
+		quotation.submit()
+
+		self.assertEqual(quotation.net_total, 290)
+		self.assertEqual(quotation.grand_total, 319)
+
+	def test_alternative_items_sales_order_mapping_with_stock_items(self):
+		from erpnext.selling.doctype.quotation.quotation import make_sales_order
+		from erpnext.stock.doctype.item.test_item import make_item
+
+		frappe.flags.args = frappe._dict()
+		item_list = []
+		stock_items = {
+			"_Test Simple Item 1": 100,
+			"_Test Alt 1": 120,
+			"_Test Alt 2": 110,
+			"_Test Simple Item 2": 200,
+		}
+
+		for item, rate in stock_items.items():
+			make_item(item, {"is_stock_item": 1})
+			item_list.append(
+				{
+					"item_code": item,
+					"qty": 1,
+					"rate": rate,
+					"is_alternative": bool("Alt" in item),
+					"warehouse": "_Test Warehouse - _TC",
+				}
+			)
+
+		quotation = make_quotation(item_list=item_list)
+
+		frappe.flags.args.selected_items = [quotation.items[2]]
+		sales_order = make_sales_order(quotation.name)
+		sales_order.delivery_date = add_days(sales_order.transaction_date, 10)
+		sales_order.save()
+
+		self.assertEqual(sales_order.items[0].item_code, "_Test Alt 2")
+		self.assertEqual(sales_order.items[1].item_code, "_Test Simple Item 2")
+		self.assertEqual(sales_order.net_total, 310)
+
+		sales_order.submit()
+		quotation.reload()
+		self.assertEqual(quotation.status, "Ordered")
+
 
 test_records = frappe.get_test_records("Quotation")
 
diff --git a/erpnext/selling/doctype/quotation_item/quotation_item.json b/erpnext/selling/doctype/quotation_item/quotation_item.json
index ca7dfd2..f2aabc5 100644
--- a/erpnext/selling/doctype/quotation_item/quotation_item.json
+++ b/erpnext/selling/doctype/quotation_item/quotation_item.json
@@ -49,6 +49,8 @@
   "pricing_rules",
   "stock_uom_rate",
   "is_free_item",
+  "is_alternative",
+  "has_alternative_item",
   "section_break_43",
   "valuation_rate",
   "column_break_45",
@@ -643,12 +645,28 @@
    "no_copy": 1,
    "options": "currency",
    "read_only": 1
+  },
+  {
+   "default": "0",
+   "fieldname": "is_alternative",
+   "fieldtype": "Check",
+   "label": "Is Alternative",
+   "print_hide": 1
+  },
+  {
+   "default": "0",
+   "fieldname": "has_alternative_item",
+   "fieldtype": "Check",
+   "hidden": 1,
+   "label": "Has Alternative Item",
+   "print_hide": 1,
+   "read_only": 1
   }
  ],
  "idx": 1,
  "istable": 1,
  "links": [],
- "modified": "2022-12-25 02:49:53.926625",
+ "modified": "2023-02-06 11:00:07.042364",
  "modified_by": "Administrator",
  "module": "Selling",
  "name": "Quotation Item",
@@ -656,5 +674,6 @@
  "permissions": [],
  "sort_field": "modified",
  "sort_order": "DESC",
+ "states": [],
  "track_changes": 1
 }
\ No newline at end of file
diff --git a/erpnext/selling/doctype/sales_order/sales_order.js b/erpnext/selling/doctype/sales_order/sales_order.js
index fb64772..a0a63f6 100644
--- a/erpnext/selling/doctype/sales_order/sales_order.js
+++ b/erpnext/selling/doctype/sales_order/sales_order.js
@@ -275,7 +275,7 @@
 		if (this.frm.doc.docstatus===0) {
 			this.frm.add_custom_button(__('Quotation'),
 				function() {
-					erpnext.utils.map_current_doc({
+					let d = erpnext.utils.map_current_doc({
 						method: "erpnext.selling.doctype.quotation.quotation.make_sales_order",
 						source_doctype: "Quotation",
 						target: me.frm,
@@ -293,7 +293,16 @@
 							docstatus: 1,
 							status: ["!=", "Lost"]
 						}
-					})
+					});
+
+					setTimeout(() => {
+						d.$parent.append(`
+							<span class='small text-muted'>
+								${__("Note: Please create Sales Orders from individual Quotations to select from among Alternative Items.")}
+							</span>
+					`);
+					}, 200);
+
 				}, __("Get Items From"));
 		}