fix: merge similar entries for serialized items in stock reconciliation (#19408)

diff --git a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
index 98a8c59..daf320e 100644
--- a/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
+++ b/erpnext/stock/doctype/stock_reconciliation/stock_reconciliation.py
@@ -52,9 +52,10 @@
 		def _changed(item):
 			item_dict = get_stock_balance_for(item.item_code, item.warehouse,
 				self.posting_date, self.posting_time, batch_no=item.batch_no)
-			if (((item.qty is None or item.qty==item_dict.get("qty")) and
-				(item.valuation_rate is None or item.valuation_rate==item_dict.get("rate")) and not item.serial_no)
-				or (item.serial_no and item.serial_no == item_dict.get("serial_nos"))):
+
+			if ((item.qty is None or item.qty==item_dict.get("qty")) and
+				(item.valuation_rate is None or item.valuation_rate==item_dict.get("rate")) and
+				(not item.serial_no or (item.serial_no == item_dict.get("serial_nos")) )):
 				return False
 			else:
 				# set default as current rates
@@ -182,9 +183,11 @@
 		from erpnext.stock.stock_ledger import get_previous_sle
 
 		sl_entries = []
+		has_serial_no = False
 		for row in self.items:
 			item = frappe.get_doc("Item", row.item_code)
 			if item.has_serial_no or item.has_batch_no:
+				has_serial_no = True
 				self.get_sle_for_serialized_items(row, sl_entries)
 			else:
 				previous_sle = get_previous_sle({
@@ -212,8 +215,14 @@
 				sl_entries.append(self.get_sle_for_items(row))
 
 		if sl_entries:
+			if has_serial_no:
+				sl_entries = self.merge_similar_item_serial_nos(sl_entries)
+
 			self.make_sl_entries(sl_entries)
 
+		if has_serial_no and sl_entries:
+			self.update_valuation_rate_for_serial_no()
+
 	def get_sle_for_serialized_items(self, row, sl_entries):
 		from erpnext.stock.stock_ledger import get_previous_sle
 
@@ -275,8 +284,18 @@
 			# update valuation rate
 			self.update_valuation_rate_for_serial_nos(row, serial_nos)
 
+	def update_valuation_rate_for_serial_no(self):
+		for d in self.items:
+			if not d.serial_no: continue
+
+			serial_nos = get_serial_nos(d.serial_no)
+			self.update_valuation_rate_for_serial_nos(d, serial_nos)
+
 	def update_valuation_rate_for_serial_nos(self, row, serial_nos):
 		valuation_rate = row.valuation_rate if self.docstatus == 1 else row.current_valuation_rate
+		if valuation_rate is None:
+			return
+
 		for d in serial_nos:
 			frappe.db.set_value("Serial No", d, 'purchase_rate', valuation_rate)
 
@@ -321,11 +340,17 @@
 			where voucher_type=%s and voucher_no=%s""", (self.doctype, self.name))
 
 		sl_entries = []
+
+		has_serial_no = False
 		for row in self.items:
 			if row.serial_no or row.batch_no or row.current_serial_no:
+				has_serial_no = True
 				self.get_sle_for_serialized_items(row, sl_entries)
 
 		if sl_entries:
+			if has_serial_no:
+				sl_entries = self.merge_similar_item_serial_nos(sl_entries)
+
 			sl_entries.reverse()
 			allow_negative_stock = frappe.db.get_value("Stock Settings", None, "allow_negative_stock")
 			self.make_sl_entries(sl_entries, allow_negative_stock=allow_negative_stock)
@@ -339,6 +364,35 @@
 				"posting_time": self.posting_time
 			})
 
+	def merge_similar_item_serial_nos(self, sl_entries):
+		# If user has put the same item in multiple row with different serial no
+		new_sl_entries = []
+		merge_similar_entries = {}
+
+		for d in sl_entries:
+			if not d.serial_no or d.actual_qty < 0:
+				new_sl_entries.append(d)
+				continue
+
+			key = (d.item_code, d.warehouse)
+			if key not in merge_similar_entries:
+				merge_similar_entries[key] = d
+			elif d.serial_no:
+				data = merge_similar_entries[key]
+				data.actual_qty += d.actual_qty
+				data.qty_after_transaction += d.qty_after_transaction
+
+				data.valuation_rate = (data.valuation_rate + d.valuation_rate) / data.actual_qty
+				data.serial_no += '\n' + d.serial_no
+
+				if data.incoming_rate:
+					data.incoming_rate = (data.incoming_rate + d.incoming_rate) / data.actual_qty
+
+		for key, value in merge_similar_entries.items():
+			new_sl_entries.append(value)
+
+		return new_sl_entries
+
 	def get_gl_entries(self, warehouse_account=None):
 		if not self.cost_center:
 			msgprint(_("Please enter Cost Center"), raise_exception=1)