Repository: frappe/erpnext
Pull request: feat: Support for Alternative Items in Quotation
> **Note:** Not to be confused with DocType "Item Alternative"

Documentation Draft: https://docs.erpnext.com/docs/v14/user/manual/en/selling/quotation/edit-wiki?wiki_page_patch=b1a88d79a9

Please check https://github.com/frappe/erpnext/issues/33609 to understand the issue

<details>
<summary>Impact on Quotation</summary>

> **Note:** It is important to maintain the right order i.e. Alternative Item rows must follow a Non-alternative item row. Grouping will be done on this basis
> <img width="350" alt="Screenshot 2023-02-06 at 4 38 13 PM" src="https://user-images.githubusercontent.com/25857446/216957147-a47d4c5c-4390-477a-9abb-a7d0ed3bb76e.png">


- Users can add an alternative item in the Quotation to give the customer a chance to choose among items in the quotation
- To add an alternative item: add a row and check **Is Alternative** 
    <img width="966" alt="Screenshot 2023-02-06 at 4 31 18 PM" src="https://user-images.githubusercontent.com/25857446/216955223-6f564c53-3abf-4455-973d-a035f254bc06.png">

- **Totals**: Only consider original (non-alternative Item)
  <img width="1039" alt="Screenshot 2023-01-31 at 5 02 44 PM" src="https://user-images.githubusercontent.com/25857446/215748953-8f4846d1-12cd-4617-8216-1c0c89ec5431.png">
- **Taxes**: Applied accordingly
  <img width="1034" alt="Screenshot 2023-01-31 at 5 03 45 PM" src="https://user-images.githubusercontent.com/25857446/215749139-91e4ca04-4aa7-4fcc-9d7e-9d91afaf7ecc.png">
</details>

<details>
<summary>Mapping to Sales Order</summary>
Consider:
<img width="1026" alt="Screenshot 2023-01-31 at 5 05 31 PM" src="https://user-images.githubusercontent.com/25857446/215749415-0d1c179e-8543-41af-a1f4-755f9811bd37.png">

- User will be asked to select an item from among the original-alternatives set, to map in the Sales Order
   ![2023-02-06 16 44 59](https://user-images.githubusercontent.com/25857446/216957964-021ceb1c-48b7-4166-a5bf-c39f45e936ef.gif)

- One of the alternatives is selected, the same is mapped and the rest in the set are skipped. Apart from that the simple items are also mapped:
   ![2023-02-06 16 46 16](https://user-images.githubusercontent.com/25857446/216958157-64e9fab2-d45e-4bab-b79f-c7ebe42c6961.gif)
</details>

<details>
<summary>Use Cases</summary>

- Item Code has alternatives which are different Items
   <img width="1014" alt="Screenshot 2023-02-06 at 4 51 28 PM" src="https://user-images.githubusercontent.com/25857446/216959057-4718ee10-58e1-4167-9c5e-71bb6b6081a7.png">
- Service Item has alternatives and non-alternatives sharing the same Item code. The case here is that the service costing is very dynamic and subjective and so the **same code** with different Item name/Description is used in the Quotation.
 <img width="1027" alt="Screenshot 2023-02-06 at 4 53 46 PM" src="https://user-images.githubusercontent.com/25857446/216959394-6ccc4ee2-b412-4a5b-b3b5-5e2ff7e194e8.png">

</details>

### Todo

- [x]  Partially ordered status
- [x] Tests


Problem statement:
Alternative line items in Quotation
Summary: I want to offer multiple variations of an item or service at different prices in the same **Quotation**. Only the main item or service shall be included in the total. The customer can decide to order any one of the proposed variations.

Let's say I'm selling flights. I want to tell my customer the prices for Economy, Business and First class. The Quotation should look like this:

Sr | Item Name | Is Alternative | Qty | Rate | Amount
---|------------|:--------------:|----:|------:|---------:
1 | Economy | | 2 | 150 | 300
2 | Business | ✓ | 2 | 200 | 400
3 | First Class | ✓ | 2 | 400 |  800
4 | **Total** | | | | **300**

The first line item is the main product offered, in this case. All following line items that have _Is Alternative_ checked, are alternatives to the first line item. Note that an alternative line item may have the same item code as the main item (but a different description).

The amount of alternative line items should be excluded from the total. It does not make sense to include them in the total, because that would make the value much higher than anything the customer will ever pay (1.500 instead of the maximum possible value of 800 for two first class tickets).

When the **Quotation** is turned into an order, exactly one of these positions must be chosen. The alternative line item only applies to **Quotation**. In **Sales Order** etc. all line items are final. For example, the **Sales Order** might look like this:

Sr | Item Name | Qty | Rate | Amount
---|------------|----:|------:|---------:
1 | Business | 2 | 200 | 400
2 | **Total** | | | **400**

This counts as a full order of all quoted items.

Internal reference: DEP-431

Hints from issue discussion:

Commit message: test: Alternative items in Quotation

- Taxes and totals, mapping, back updation

Patch to review:
diff --git a/erpnext/controllers/taxes_and_totals.py b/erpnext/controllers/taxes_and_totals.py
index 8c403aa9bfe2..1edd7bf85e1f 100644
--- a/erpnext/controllers/taxes_and_totals.py
+++ b/erpnext/controllers/taxes_and_totals.py
@@ -24,11 +24,19 @@ class calculate_taxes_and_totals(object):
 	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 @@ def calculate_tax_withholding_net_total(self):
 		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 @@ def calculate_tax_withholding_net_total(self):
 			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 @@ def calculate_item_values(self):
 			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 @@ def determine_exclusive_rate(self):
 		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 @@ def calculate_net_total(self):
 			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 @@ def calculate_taxes(self):
 			]
 		)
 
-		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 @@ def calculate_taxes(self):
 				# 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 @@ def calculate_taxes(self):
 					)
 
 				# 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_totals(self):
 	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 @@ def apply_discount_amount(self):
 
 			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 @@ def apply_discount_amount(self):
 						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 a87c3ec9514b..623b338e181c 100644
--- a/erpnext/public/js/controllers/taxes_and_totals.js
+++ b/erpnext/public/js/controllers/taxes_and_totals.js
@@ -91,6 +91,9 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
 	}
 
 	_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 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
 	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 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
 		});
 		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 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
 		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 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
 			}
 		});
 
-		$.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 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
 				// 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 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
 				}
 
 				// 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 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
 
 	_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 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
 			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 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
 
 					// 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 @@ erpnext.taxes_and_totals = class TaxesAndTotals extends erpnext.payments {
 		}
 
 	}
+
+	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 b348bd35754f..f77dce8f1abd 100644
--- a/erpnext/selling/doctype/quotation/quotation.js
+++ b/erpnext/selling/doctype/quotation/quotation.js
@@ -90,7 +90,7 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
 				|| 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 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
 
 	}
 
+	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 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
 			}
 		})
 	}
+
+	show_alternative_items_dialog() {
+		var 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 063813b2dc70..185f63c345e0 100644
--- a/erpnext/selling/doctype/quotation/quotation.py
+++ b/erpnext/selling/doctype/quotation/quotation.py
@@ -35,6 +35,9 @@ def validate(self):
 
 		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 @@ def validate_shopping_cart_items(self):
 					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 @@ def get_ordered_status(self):
 			)
 		)
 
-		status = "Open"
-		if ordered_items:
-			status = "Ordered"
+		if not ordered_items:
+			return status
 
-			for item in self.get("items"):
-				if item.qty > ordered_items.get(item.item_code, 0.0):
-					status = "Partially Ordered"
+		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"
 
 		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 print_other_charges(self, docname):
 	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 @@ def _make_sales_order(source_name, target_doc=None, ignore_permissions=False):
 		)
 	)
 
+	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,20 @@ def update_item(obj, target, source_parent):
 			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. Simple row: Map if adequate qty
+		2. Has Alternative Item: Map if no alternative was selected against original item and #1
+		3. Is Alternative Item: Map if alternative was selected against original item and #1
+		"""
+		has_qty = item.qty > 0
+		if not (item.is_alternative or item.has_alternative_item):
+			# No alternative items in doc or current row is a simple item (without alternatives)
+			return has_qty
+
+		return (item.name in selected_rows) and has_qty
+
 	doclist = get_mapped_doc(
 		"Quotation",
 		source_name,
@@ -253,7 +323,7 @@ def update_item(obj, target, source_parent):
 				"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},
diff --git a/erpnext/selling/doctype/quotation/test_quotation.py b/erpnext/selling/doctype/quotation/test_quotation.py
index cdf5f5d00c58..67f6518657eb 100644
--- a/erpnext/selling/doctype/quotation/test_quotation.py
+++ b/erpnext/selling/doctype/quotation/test_quotation.py
@@ -457,6 +457,139 @@ def test_packed_items_indices_are_reset_when_product_bundle_is_deleted_from_item
 			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 ca7dfd23378f..f2aabc524004 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


Code review comments:
diff_hunk: @@ -220,17 +234,111 @@ erpnext.selling.QuotationController = class QuotationController extends erpnext.
 			}
 		})
 	}
+
+	show_alternative_items_dialog() {
+		var me = this;
path: erpnext/selling/doctype/quotation/quotation.js
text: @user1:
```suggestion
		let me = this;
```