fix: duplicate leave expiry creation (#21505)

* fix: validate existing ledger entries to avoid duplicates

* patch: remove duplicate ledger entries created

* fix: consider only submitted ledger entries

* fix: delete duplicate leaves from the ledger

* fix: check if duplicate ledger entry exists

* chore: formatting changes

Co-authored-by: Nabin Hait <nabinhait@gmail.com>
diff --git a/erpnext/hr/doctype/leave_application/leave_application.py b/erpnext/hr/doctype/leave_application/leave_application.py
index 47b1bb7..d2620be 100755
--- a/erpnext/hr/doctype/leave_application/leave_application.py
+++ b/erpnext/hr/doctype/leave_application/leave_application.py
@@ -549,7 +549,7 @@
 
 	return _get_remaining_leaves(total_leaves, allocation.to_date)
 
-def get_leaves_for_period(employee, leave_type, from_date, to_date):
+def get_leaves_for_period(employee, leave_type, from_date, to_date, do_not_skip_expired_leaves=False):
 	leave_entries = get_leave_entries(employee, leave_type, from_date, to_date)
 	leave_days = 0
 
@@ -559,8 +559,8 @@
 		if  inclusive_period and leave_entry.transaction_type == 'Leave Encashment':
 			leave_days += leave_entry.leaves
 
-		elif inclusive_period and leave_entry.transaction_type == 'Leave Allocation' \
-			and leave_entry.is_expired and not skip_expiry_leaves(leave_entry, to_date):
+		elif inclusive_period and leave_entry.transaction_type == 'Leave Allocation' and leave_entry.is_expired \
+			and (do_not_skip_expired_leaves or not skip_expiry_leaves(leave_entry, to_date)):
 			leave_days += leave_entry.leaves
 
 		elif leave_entry.transaction_type == 'Leave Application':
diff --git a/erpnext/hr/doctype/leave_ledger_entry/leave_ledger_entry.py b/erpnext/hr/doctype/leave_ledger_entry/leave_ledger_entry.py
index 9ed58c9..63559c4 100644
--- a/erpnext/hr/doctype/leave_ledger_entry/leave_ledger_entry.py
+++ b/erpnext/hr/doctype/leave_ledger_entry/leave_ledger_entry.py
@@ -88,32 +88,40 @@
 	}, fieldname=['name'])
 
 def process_expired_allocation():
-	''' Check if a carry forwarded allocation has expired and create a expiry ledger entry '''
+	''' Check if a carry forwarded allocation has expired and create a expiry ledger entry
+		Case 1: carry forwarded expiry period is set for the leave type,
+			create a separate leave expiry entry against each entry of carry forwarded and non carry forwarded leaves
+		Case 2: leave type has no specific expiry period for carry forwarded leaves
+			and there is no carry forwarded leave allocation, create a single expiry against the remaining leaves.
+	'''
 
 	# fetch leave type records that has carry forwarded leaves expiry
 	leave_type_records = frappe.db.get_values("Leave Type", filters={
 			'expire_carry_forwarded_leaves_after_days': (">", 0)
 		}, fieldname=['name'])
 
-	leave_type = [record[0] for record in leave_type_records]
+	leave_type = [record[0] for record in leave_type_records] or ['']
 
-	expired_allocation = frappe.db.sql_list("""SELECT name
-		FROM `tabLeave Ledger Entry`
-		WHERE
-			`transaction_type`='Leave Allocation'
-			AND `is_expired`=1""")
-
-	expire_allocation = frappe.get_all("Leave Ledger Entry",
-		fields=['leaves', 'to_date', 'employee', 'leave_type', 'is_carry_forward', 'transaction_name as name', 'transaction_type'],
-		filters={
-			'to_date': ("<", today()),
-			'transaction_type': 'Leave Allocation',
-			'transaction_name': ('not in', expired_allocation)
-		},
-		or_filters={
-			'is_carry_forward': 0,
-			'leave_type': ('in', leave_type)
-		})
+	# fetch non expired leave ledger entry of transaction_type allocation
+	expire_allocation = frappe.db.sql("""
+		SELECT
+			leaves, to_date, employee, leave_type,
+			is_carry_forward, transaction_name as name, transaction_type
+		FROM `tabLeave Ledger Entry` l
+		WHERE (NOT EXISTS
+			(SELECT name
+				FROM `tabLeave Ledger Entry`
+				WHERE
+					transaction_name = l.transaction_name
+					AND transaction_type = 'Leave Allocation'
+					AND name<>l.name
+					AND docstatus = 1
+					AND (
+						is_carry_forward=l.is_carry_forward
+						OR (is_carry_forward = 0 AND leave_type not in %s)
+			)))
+			AND transaction_type = 'Leave Allocation'
+			AND to_date < %s""", (leave_type, today()), as_dict=1)
 
 	if expire_allocation:
 		create_expiry_ledger_entry(expire_allocation)
@@ -133,6 +141,7 @@
 			'employee': allocation.employee,
 			'leave_type': allocation.leave_type,
 			'to_date': ('<=', allocation.to_date),
+			'docstatus': 1
 		}, fieldname=['SUM(leaves)'])
 
 @frappe.whitelist()
@@ -159,7 +168,8 @@
 def expire_carried_forward_allocation(allocation):
 	''' Expires remaining leaves in the on carried forward allocation '''
 	from erpnext.hr.doctype.leave_application.leave_application import get_leaves_for_period
-	leaves_taken = get_leaves_for_period(allocation.employee, allocation.leave_type, allocation.from_date, allocation.to_date)
+	leaves_taken = get_leaves_for_period(allocation.employee, allocation.leave_type,
+		allocation.from_date, allocation.to_date, do_not_skip_expired_leaves=True)
 	leaves = flt(allocation.leaves) + flt(leaves_taken)
 
 	# allow expired leaves entry to be created
diff --git a/erpnext/patches.txt b/erpnext/patches.txt
index 0edadcc..2747281 100644
--- a/erpnext/patches.txt
+++ b/erpnext/patches.txt
@@ -678,6 +678,7 @@
 erpnext.patches.v12_0.fix_quotation_expired_status
 erpnext.patches.v12_0.update_appointment_reminder_scheduler_entry
 erpnext.patches.v12_0.retain_permission_rules_for_video_doctype
+erpnext.patches.v12_0.remove_duplicate_leave_ledger_entries
 erpnext.patches.v13_0.patch_to_fix_reverse_linking_in_additional_salary_encashment_and_incentive
 execute:frappe.delete_doc_if_exists("Page", "appointment-analytic")
 execute:frappe.rename_doc("Desk Page", "Getting Started", "Home", force=True)
diff --git a/erpnext/patches/v12_0/remove_duplicate_leave_ledger_entries.py b/erpnext/patches/v12_0/remove_duplicate_leave_ledger_entries.py
new file mode 100644
index 0000000..98a2fcf
--- /dev/null
+++ b/erpnext/patches/v12_0/remove_duplicate_leave_ledger_entries.py
@@ -0,0 +1,44 @@
+# Copyright (c) 2018, Frappe and Contributors
+# License: GNU General Public License v3. See license.txt
+
+from __future__ import unicode_literals
+import frappe
+
+def execute():
+	"""Delete duplicate leave ledger entries of type allocation created."""
+	if not frappe.db.a_row_exists("Leave Ledger Entry"):
+		return
+
+	duplicate_records_list = get_duplicate_records()
+	delete_duplicate_ledger_entries(duplicate_records_list)
+
+def get_duplicate_records():
+	"""Fetch all but one duplicate records from the list of expired leave allocation."""
+	return frappe.db.sql_list("""
+		WITH duplicate_records AS
+		(SELECT
+			name, transaction_name, is_carry_forward,
+			ROW_NUMBER() over(partition by transaction_name order by creation)as row
+		FROM `tabLeave Ledger Entry` l
+		WHERE (EXISTS
+			(SELECT name
+				FROM `tabLeave Ledger Entry`
+				WHERE
+					transaction_name = l.transaction_name
+					AND transaction_type = 'Leave Allocation'
+					AND name <> l.name
+					AND employee = l.employee
+					AND docstatus = 1
+					AND leave_type = l.leave_type
+					AND is_carry_forward=l.is_carry_forward
+					AND to_date = l.to_date
+					AND from_date = l.from_date
+					AND is_expired = 1
+		)))
+		SELECT name FROM duplicate_records WHERE row > 1
+	""")
+
+def delete_duplicate_ledger_entries(duplicate_records_list):
+	"""Delete duplicate leave ledger entries."""
+	if duplicate_records_list:
+		frappe.db.sql(''' DELETE FROM `tabLeave Ledger Entry` WHERE name in {0}'''.format(tuple(duplicate_records_list))) #nosec
\ No newline at end of file