refactor: serial and batch bundle for Maintenance Schedule
diff --git a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.js b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.js
index 5252798..4480ae5 100644
--- a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.js
+++ b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.js
@@ -7,6 +7,19 @@
 		frm.set_query('contact_person', erpnext.queries.contact_query);
 		frm.set_query('customer_address', erpnext.queries.address_query);
 		frm.set_query('customer', erpnext.queries.customer);
+
+		frm.set_query('serial_and_batch_bundle', 'items', (doc, cdt, cdn) => {
+			let item = locals[cdt][cdn];
+
+			return {
+				filters: {
+					'item_code': item.item_code,
+					'voucher_type': 'Maintenance Schedule',
+					'type_of_transaction': 'Maintenance',
+					'company': doc.company,
+				}
+			}
+		});
 	},
 	onload: function (frm) {
 		if (!frm.doc.status) {
diff --git a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py
index 95e2d69..e5bb9e8 100644
--- a/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py
+++ b/erpnext/maintenance/doctype/maintenance_schedule/maintenance_schedule.py
@@ -7,7 +7,6 @@
 
 from erpnext.setup.doctype.employee.employee import get_holiday_list_for_employee
 from erpnext.stock.doctype.serial_no.serial_no import get_serial_nos
-from erpnext.stock.utils import get_valid_serial_nos
 from erpnext.utilities.transaction_base import TransactionBase, delete_events
 
 
@@ -74,10 +73,14 @@
 
 		email_map = {}
 		for d in self.get("items"):
-			if d.serial_no:
-				serial_nos = get_valid_serial_nos(d.serial_no)
-				self.validate_serial_no(d.item_code, serial_nos, d.start_date)
-				self.update_amc_date(serial_nos, d.end_date)
+			if d.serial_and_batch_bundle:
+				serial_nos = frappe.get_doc(
+					"Serial and Batch Bundle", d.serial_and_batch_bundle
+				).get_serial_nos()
+
+				if serial_nos:
+					self.validate_serial_no(d.item_code, serial_nos, d.start_date)
+					self.update_amc_date(serial_nos, d.end_date)
 
 			no_email_sp = []
 			if d.sales_person not in email_map:
@@ -241,9 +244,27 @@
 		self.validate_maintenance_detail()
 		self.validate_dates_with_periodicity()
 		self.validate_sales_order()
+		self.validate_serial_no_bundle()
 		if not self.schedules or self.validate_items_table_change() or self.validate_no_of_visits():
 			self.generate_schedule()
 
+	def validate_serial_no_bundle(self):
+		ids = [d.serial_and_batch_bundle for d in self.items if d.serial_and_batch_bundle]
+
+		if not ids:
+			return
+
+		voucher_nos = frappe.get_all(
+			"Serial and Batch Bundle", fields=["name", "voucher_type"], filters={"name": ("in", ids)}
+		)
+
+		for row in voucher_nos:
+			if row.voucher_type != "Maintenance Schedule":
+				msg = f"""Serial and Batch Bundle {row.name}
+					should have voucher type as 'Maintenance Schedule'"""
+
+				frappe.throw(_(msg))
+
 	def on_update(self):
 		self.db_set("status", "Draft")
 
@@ -341,9 +362,14 @@
 
 	def on_cancel(self):
 		for d in self.get("items"):
-			if d.serial_no:
-				serial_nos = get_valid_serial_nos(d.serial_no)
-				self.update_amc_date(serial_nos)
+			if d.serial_and_batch_bundle:
+				serial_nos = frappe.get_doc(
+					"Serial and Batch Bundle", d.serial_and_batch_bundle
+				).get_serial_nos()
+
+				if serial_nos:
+					self.update_amc_date(serial_nos)
+
 		self.db_set("status", "Cancelled")
 		delete_events(self.doctype, self.name)
 
@@ -397,11 +423,15 @@
 		target.maintenance_schedule_detail = s_id
 
 	def update_serial(source, target, parent):
-		serial_nos = get_serial_nos(target.serial_no)
-		if len(serial_nos) == 1:
-			target.serial_no = serial_nos[0]
-		else:
-			target.serial_no = ""
+		if source.serial_and_batch_bundle:
+			serial_nos = frappe.get_doc(
+				"Serial and Batch Bundle", source.serial_and_batch_bundle
+			).get_serial_nos()
+
+			if len(serial_nos) == 1:
+				target.serial_no = serial_nos[0]
+			else:
+				target.serial_no = ""
 
 	doclist = get_mapped_doc(
 		"Maintenance Schedule",
diff --git a/erpnext/maintenance/doctype/maintenance_schedule_item/maintenance_schedule_item.json b/erpnext/maintenance/doctype/maintenance_schedule_item/maintenance_schedule_item.json
index 3dacdea..d8e02cf 100644
--- a/erpnext/maintenance/doctype/maintenance_schedule_item/maintenance_schedule_item.json
+++ b/erpnext/maintenance/doctype/maintenance_schedule_item/maintenance_schedule_item.json
@@ -20,7 +20,9 @@
   "sales_person",
   "reference",
   "serial_no",
-  "sales_order"
+  "sales_order",
+  "column_break_ugqr",
+  "serial_and_batch_bundle"
  ],
  "fields": [
   {
@@ -121,7 +123,8 @@
    "fieldtype": "Small Text",
    "label": "Serial No",
    "oldfieldname": "serial_no",
-   "oldfieldtype": "Small Text"
+   "oldfieldtype": "Small Text",
+   "read_only": 1
   },
   {
    "fieldname": "sales_order",
@@ -144,17 +147,31 @@
   {
    "fieldname": "column_break_10",
    "fieldtype": "Column Break"
+  },
+  {
+   "fieldname": "column_break_ugqr",
+   "fieldtype": "Column Break"
+  },
+  {
+   "fieldname": "serial_and_batch_bundle",
+   "fieldtype": "Link",
+   "label": "Serial and Batch Bundle",
+   "no_copy": 1,
+   "options": "Serial and Batch Bundle",
+   "print_hide": 1
   }
  ],
  "idx": 1,
  "istable": 1,
  "links": [],
- "modified": "2021-04-15 16:09:47.311994",
+ "modified": "2023-03-22 18:44:36.816037",
  "modified_by": "Administrator",
  "module": "Maintenance",
  "name": "Maintenance Schedule Item",
+ "naming_rule": "Random",
  "owner": "Administrator",
  "permissions": [],
  "sort_field": "modified",
- "sort_order": "DESC"
+ "sort_order": "DESC",
+ "states": []
 }
\ No newline at end of file
diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json
index 00d6b3f..337c6dd 100644
--- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json
+++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.json
@@ -177,14 +177,14 @@
    "in_list_view": 1,
    "in_standard_filter": 1,
    "label": "Warehouse",
-   "options": "Warehouse",
-   "reqd": 1
+   "mandatory_depends_on": "eval:doc.type_of_transaction != \"Maintenance\"",
+   "options": "Warehouse"
   },
   {
    "fieldname": "type_of_transaction",
    "fieldtype": "Select",
    "label": "Type of Transaction",
-   "options": "\nInward\nOutward",
+   "options": "\nInward\nOutward\nMaintenance",
    "reqd": 1
   },
   {
@@ -237,7 +237,7 @@
  "index_web_pages_for_search": 1,
  "is_submittable": 1,
  "links": [],
- "modified": "2023-03-21 10:52:25.105421",
+ "modified": "2023-03-22 18:56:37.035516",
  "modified_by": "Administrator",
  "module": "Stock",
  "name": "Serial and Batch Bundle",
diff --git a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
index 9013ef0..c06f63f 100644
--- a/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
+++ b/erpnext/stock/doctype/serial_and_batch_bundle/serial_and_batch_bundle.py
@@ -8,7 +8,7 @@
 from frappe import _, bold
 from frappe.model.document import Document
 from frappe.query_builder.functions import CombineDatetime, Sum
-from frappe.utils import cint, flt, get_link_to_form, today
+from frappe.utils import add_days, cint, flt, get_link_to_form, today
 from pypika import Case
 
 from erpnext.stock.serial_batch_bundle import BatchNoBundleValuation, SerialNoBundleValuation
@@ -22,11 +22,14 @@
 	def validate(self):
 		self.validate_serial_and_batch_no()
 		self.validate_duplicate_serial_and_batch_no()
-		# self.validate_voucher_no()
-		self.check_future_entries_exists()
-		self.validate_serial_nos_inventory()
+		self.validate_voucher_no()
 
 	def before_save(self):
+		if self.type_of_transaction == "Maintenance":
+			return
+
+		self.check_future_entries_exists()
+		self.validate_serial_nos_inventory()
 		self.set_is_outward()
 		self.calculate_qty_and_amount()
 		self.set_warehouse()
@@ -97,7 +100,7 @@
 				d.incoming_rate = abs(sn_obj.serial_no_incoming_rate.get(d.serial_no, 0.0))
 			else:
 				d.incoming_rate = abs(sn_obj.batch_avg_rate.get(d.batch_no))
-				available_qty = sn_obj.batch_available_qty.get(d.batch_no) + d.qty
+				available_qty = flt(sn_obj.batch_available_qty.get(d.batch_no)) + flt(d.qty)
 
 				self.validate_negative_batch(d.batch_no, available_qty)
 
@@ -184,35 +187,37 @@
 		self.set_incoming_rate(save=True, row=row)
 		self.calculate_qty_and_amount(save=True)
 		self.validate_quantity(row)
+		self.set_warranty_expiry_date(row)
 
-	def validate_voucher_no(self):
-		if self.is_new():
+	def set_warranty_expiry_date(self):
+		if not (self.docstatus == 1 and self.voucher_type == "Delivery Note" and self.has_serial_no):
 			return
 
+		warranty_period = frappe.get_cached_value("Item", self.item_code, "warranty_period")
+
+		if not warranty_period:
+			return
+
+		warranty_expiry_date = add_days(self.posting_date, cint(warranty_period))
+
+		serial_nos = self.get_serial_nos()
+		if not serial_nos:
+			return
+
+		sn_table = frappe.qb.DocType("Serial No")
+		(
+			frappe.qb.update(sn_table)
+			.set(sn_table.warranty_expiry_date, warranty_expiry_date)
+			.where(sn_table.name.isin(serial_nos))
+		).run()
+
+	def validate_voucher_no(self):
 		if not (self.voucher_type and self.voucher_no):
 			return
 
-		if not frappe.db.exists(self.voucher_type, self.voucher_no):
+		if self.voucher_no and not frappe.db.exists(self.voucher_type, self.voucher_no):
 			frappe.throw(_(f"The {self.voucher_type} # {self.voucher_no} does not exist"))
 
-		bundles = frappe.get_all(
-			"Serial and Batch Bundle",
-			filters={
-				"voucher_no": self.voucher_no,
-				"is_cancelled": 0,
-				"name": ["!=", self.name],
-				"item_code": self.item_code,
-				"warehouse": self.warehouse,
-			},
-		)
-
-		if bundles:
-			frappe.throw(
-				_(
-					f"The {self.voucher_type} # {self.voucher_no} already has a Serial and Batch Bundle {bundles[0].name}"
-				)
-			)
-
 	def check_future_entries_exists(self):
 		if not self.has_serial_no:
 			return
@@ -413,12 +418,6 @@
 		self.delink_reference_from_batch()
 		self.clear_table()
 
-	def on_update(self):
-		self.validate_negative_stock()
-
-	def validate_negative_stock(self):
-		pass
-
 
 @frappe.whitelist()
 @frappe.validate_and_sanitize_search_inputs
diff --git a/erpnext/stock/doctype/serial_no/serial_no.json b/erpnext/stock/doctype/serial_no/serial_no.json
index 1750439..8dba698 100644
--- a/erpnext/stock/doctype/serial_no/serial_no.json
+++ b/erpnext/stock/doctype/serial_no/serial_no.json
@@ -79,12 +79,15 @@
    "fieldtype": "Column Break"
   },
   {
+   "fetch_from": "item_code.item_name",
+   "fetch_if_empty": 1,
    "fieldname": "item_name",
    "fieldtype": "Data",
    "label": "Item Name",
    "read_only": 1
   },
   {
+   "fetch_from": "item_code.description",
    "fieldname": "description",
    "fieldtype": "Text",
    "label": "Description",
@@ -188,6 +191,7 @@
    "width": "150px"
   },
   {
+   "fetch_from": "item_code.warranty_period",
    "fieldname": "warranty_period",
    "fieldtype": "Int",
    "label": "Warranty Period (Days)",
diff --git a/erpnext/stock/serial_batch_bundle.py b/erpnext/stock/serial_batch_bundle.py
index f82c309..f2de819 100644
--- a/erpnext/stock/serial_batch_bundle.py
+++ b/erpnext/stock/serial_batch_bundle.py
@@ -385,6 +385,7 @@
 				AND child.serial_no IN ({', '.join([frappe.db.escape(s) for s in serial_nos])})
 				AND child.is_outward = 0
 				AND parent.docstatus = 1
+				AND parent.type_of_transaction != 'Maintenance'
 				AND parent.is_cancelled = 0
 				AND child.warehouse = {frappe.db.escape(self.sle.warehouse)}
 				AND parent.item_code = {frappe.db.escape(self.sle.item_code)}
@@ -521,6 +522,7 @@
 				& (parent.item_code == self.sle.item_code)
 				& (parent.docstatus == 1)
 				& (parent.is_cancelled == 0)
+				& (parent.type_of_transaction != "Maintenance")
 			)
 			.where(timestamp_condition)
 			.groupby(child.batch_no)