Earned Leave (#14143)

* Earned Leave Allocations will be initially zero, escaped validation in leave allocation to allow this

* Earned Leave monthly scheduler method, test

* remove whitelist of method
diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index 938f7fa..815e2eb 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -238,7 +238,8 @@
 		"erpnext.assets.doctype.asset.asset.make_post_gl_entry"
   ],
 	"monthly": [
-		"erpnext.accounts.doctype.sales_invoice.sales_invoice.booked_deferred_revenue"
+		"erpnext.accounts.doctype.sales_invoice.sales_invoice.booked_deferred_revenue",
+		"erpnext.hr.utils.allocate_earned_leaves"
 	]
 }
 
diff --git a/erpnext/hr/doctype/leave_allocation/leave_allocation.py b/erpnext/hr/doctype/leave_allocation/leave_allocation.py
index 8432cfe..7cffa4c 100755
--- a/erpnext/hr/doctype/leave_allocation/leave_allocation.py
+++ b/erpnext/hr/doctype/leave_allocation/leave_allocation.py
@@ -43,16 +43,16 @@
 	def on_update_after_submit(self):
 		self.validate_new_leaves_allocated_value()
 		self.set_total_leaves_allocated()
-		
+
 		frappe.db.set(self,'carry_forwarded_leaves', flt(self.carry_forwarded_leaves))
 		frappe.db.set(self,'total_leaves_allocated',flt(self.total_leaves_allocated))
-		
+
 		self.validate_against_leave_applications()
 
 	def validate_period(self):
 		if date_diff(self.to_date, self.from_date) <= 0:
 			frappe.throw(_("To date cannot be before from date"))
-			
+
 	def validate_lwp(self):
 		if frappe.db.get_value("Leave Type", self.leave_type, "is_lwp"):
 			frappe.throw(_("Leave Type {0} cannot be allocated since it is leave without pay").format(self.leave_type))
@@ -66,45 +66,45 @@
 		leave_allocation = frappe.db.sql("""
 			select name from `tabLeave Allocation`
 			where employee=%s and leave_type=%s and docstatus=1
-			and to_date >= %s and from_date <= %s""", 
+			and to_date >= %s and from_date <= %s""",
 			(self.employee, self.leave_type, self.from_date, self.to_date))
 
 		if leave_allocation:
 			frappe.msgprint(_("{0} already allocated for Employee {1} for period {2} to {3}")
 				.format(self.leave_type, self.employee, formatdate(self.from_date), formatdate(self.to_date)))
-			
+
 			frappe.throw(_('Reference') + ': <a href="#Form/Leave Allocation/{0}">{0}</a>'
 				.format(leave_allocation[0][0]), OverlapError)
-				
+
 	def validate_back_dated_allocation(self):
 		future_allocation = frappe.db.sql("""select name, from_date from `tabLeave Allocation`
-			where employee=%s and leave_type=%s and docstatus=1 and from_date > %s 
+			where employee=%s and leave_type=%s and docstatus=1 and from_date > %s
 			and carry_forward=1""", (self.employee, self.leave_type, self.to_date), as_dict=1)
-		
+
 		if future_allocation:
 			frappe.throw(_("Leave cannot be allocated before {0}, as leave balance has already been carry-forwarded in the future leave allocation record {1}")
-				.format(formatdate(future_allocation[0].from_date), future_allocation[0].name), 
+				.format(formatdate(future_allocation[0].from_date), future_allocation[0].name),
 					BackDatedAllocationError)
 
 	def set_total_leaves_allocated(self):
-		self.carry_forwarded_leaves = get_carry_forwarded_leaves(self.employee, 
+		self.carry_forwarded_leaves = get_carry_forwarded_leaves(self.employee,
 			self.leave_type, self.from_date, self.carry_forward)
-			
+
 		self.total_leaves_allocated = flt(self.carry_forwarded_leaves) + flt(self.new_leaves_allocated)
-		
-		if not self.total_leaves_allocated:
-			frappe.throw(_("Total leaves allocated is mandatory"))
+
+		if not self.total_leaves_allocated and not frappe.db.get_value("Leave Type", self.leave_type, "is_earned_leave"):
+			frappe.throw(_("Total leaves allocated is mandatory for Leave Type {0}".format(self.leave_type)))
 
 	def validate_total_leaves_allocated(self):
 		# Adding a day to include To Date in the difference
 		date_difference = date_diff(self.to_date, self.from_date) + 1
 		if date_difference < self.total_leaves_allocated:
 			frappe.throw(_("Total allocated leaves are more than days in the period"), OverAllocationError)
-			
+
 	def validate_against_leave_applications(self):
-		leaves_taken = get_approved_leaves_for_period(self.employee, self.leave_type, 
+		leaves_taken = get_approved_leaves_for_period(self.employee, self.leave_type,
 			self.from_date, self.to_date)
-		
+
 		if flt(leaves_taken) > flt(self.total_leaves_allocated):
 			if frappe.db.get_value("Leave Type", self.leave_type, "allow_negative"):
 				frappe.msgprint(_("Note: Total allocated leaves {0} shouldn't be less than already approved leaves {1} for the period").format(self.total_leaves_allocated, leaves_taken))
@@ -137,10 +137,10 @@
 @frappe.whitelist()
 def get_carry_forwarded_leaves(employee, leave_type, date, carry_forward=None):
 	carry_forwarded_leaves = 0
-	
+
 	if carry_forward:
 		validate_carry_forward(leave_type)
-		
+
 		previous_allocation = frappe.db.sql("""
 			select name, from_date, to_date, total_leaves_allocated
 			from `tabLeave Allocation`
@@ -148,14 +148,13 @@
 			order by to_date desc limit 1
 		""", (employee, leave_type, date), as_dict=1)
 		if previous_allocation:
-			leaves_taken = get_approved_leaves_for_period(employee, leave_type, 
+			leaves_taken = get_approved_leaves_for_period(employee, leave_type,
 				previous_allocation[0].from_date, previous_allocation[0].to_date)
-		
+
 			carry_forwarded_leaves = flt(previous_allocation[0].total_leaves_allocated) - flt(leaves_taken)
-			
+
 	return carry_forwarded_leaves
-		
+
 def validate_carry_forward(leave_type):
 	if not frappe.db.get_value("Leave Type", leave_type, "is_carry_forward"):
 		frappe.throw(_("Leave Type {0} cannot be carry-forwarded").format(leave_type))
-	
diff --git a/erpnext/hr/doctype/leave_application/test_leave_application.py b/erpnext/hr/doctype/leave_application/test_leave_application.py
index b4f4c1c..5506d60 100644
--- a/erpnext/hr/doctype/leave_application/test_leave_application.py
+++ b/erpnext/hr/doctype/leave_application/test_leave_application.py
@@ -7,7 +7,7 @@
 
 from erpnext.hr.doctype.leave_application.leave_application import LeaveDayBlockedError, OverlapError, NotAnOptionalHoliday, get_leave_balance_on
 from frappe.permissions import clear_user_permissions_for_doctype
-from frappe.utils import add_days, nowdate, now_datetime
+from frappe.utils import add_days, nowdate, now_datetime, get_datetime
 
 test_dependencies = ["Leave Allocation", "Leave Block List"]
 
@@ -387,25 +387,32 @@
 
 		self.assertRaises(frappe.ValidationError, leave_application.insert)
 
-	# def test_earned_leave(self):
-	# 	leave_period = get_leave_period()
-	# 	employee = get_employee()
-	#
-	# 	leave_type = frappe.get_doc(dict(
-	# 		leave_type_name = 'Test Earned Leave Type',
-	# 		doctype = 'Leave Type',
-	# 		is_earned_leave = 1,
-	# 		earned_leave_frequency = 'Monthly',
-	# 		rounding = 0.5
-	# 	)).insert()
-	#
-	# 	allocate_leaves(employee, leave_period, leave_type.name, 0, eligible_leaves = 12)
-	#
-	# 	# this method will be called by scheduler
-	# 	allocate_earned_leaves(leave_type.name, leave_period, as_on = half_of_leave_period)
-	#
-	# 	self.assertEqual(get_leave_balance(employee, leave_period, leave_type.name), 6)
+	def test_earned_leave(self):
+		leave_period = get_leave_period()
+		employee = get_employee()
 
+		leave_type = frappe.get_doc(dict(
+			leave_type_name = 'Test Earned Leave Type',
+			doctype = 'Leave Type',
+			is_earned_leave = 1,
+			earned_leave_frequency = 'Monthly',
+			rounding = 0.5,
+			max_leaves_allowed = 6
+		)).insert()
+		leave_policy = frappe.get_doc({
+			"doctype": "Leave Policy",
+			"leave_policy_details": [{"leave_type": leave_type.name, "annual_allocation": 6}]
+		}).insert()
+		frappe.db.set_value("Employee", employee.name, "leave_policy", leave_policy.name)
+
+		allocate_leaves(employee, leave_period, leave_type.name, 0, eligible_leaves = 12)
+
+		from erpnext.hr.utils import allocate_earned_leaves
+		i = 0
+		while(i<14):
+			allocate_earned_leaves()
+			i += 1
+		self.assertEqual(get_leave_balance_on(employee.name, leave_type.name, nowdate()), 6)
 
 def make_allocation_record(employee=None, leave_type=None):
 	frappe.db.sql("delete from `tabLeave Allocation`")
diff --git a/erpnext/hr/doctype/leave_period/leave_period.py b/erpnext/hr/doctype/leave_period/leave_period.py
index 4097169..39001ee 100644
--- a/erpnext/hr/doctype/leave_period/leave_period.py
+++ b/erpnext/hr/doctype/leave_period/leave_period.py
@@ -71,7 +71,8 @@
 		allocation.leave_type = leave_type
 		allocation.from_date = self.from_date
 		allocation.to_date = self.to_date
-		allocation.new_leaves_allocated = new_leaves_allocated
+		'''Earned Leaves are allocated by scheduler, initially allocate 0'''
+		allocation.new_leaves_allocated = new_leaves_allocated if not frappe.db.get_value("Leave Type", leave_type, "is_earned_leave") else 0
 		allocation.leave_period = self.name
 		if self.carry_forward_leaves:
 			if frappe.db.get_value("Leave Type", leave_type, "is_carry_forward"):
diff --git a/erpnext/hr/doctype/leave_type/leave_type.json b/erpnext/hr/doctype/leave_type/leave_type.json
index ef66a0a..1e0b048 100644
--- a/erpnext/hr/doctype/leave_type/leave_type.json
+++ b/erpnext/hr/doctype/leave_type/leave_type.json
@@ -584,7 +584,7 @@
    "default": "0.5",
    "depends_on": "is_earned_leave",
    "fieldname": "rounding",
-   "fieldtype": "Float",
+   "fieldtype": "Select",
    "hidden": 0,
    "ignore_user_permissions": 0,
    "ignore_xss_filter": 0,
@@ -595,6 +595,7 @@
    "label": "Rounding",
    "length": 0,
    "no_copy": 0,
+   "options": "0.5\n1.0",
    "permlevel": 0,
    "precision": "",
    "print_hide": 0,
diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py
index 20fe666..4e937c6 100644
--- a/erpnext/hr/utils.py
+++ b/erpnext/hr/utils.py
@@ -4,7 +4,7 @@
 from __future__ import unicode_literals
 import frappe
 from frappe import _
-from frappe.utils import formatdate, format_datetime, getdate, get_datetime, nowdate
+from frappe.utils import formatdate, format_datetime, getdate, get_datetime, nowdate, flt
 from frappe.model.document import Document
 from frappe.desk.form import assign_to
 
@@ -241,3 +241,52 @@
 		pd.parent=pp.name where pd.start_date<=%s and pd.end_date>= %s
 		and pp.company=%s""", (from_date, to_date, company), as_dict=1)
 	return payroll_period[0] if payroll_period else None
+
+
+def allocate_earned_leaves():
+	'''Allocate earned leaves to Employees'''
+	e_leave_types = frappe.get_all("Leave Type",
+		fields=["name", "max_leaves_allowed", "earned_leave_frequency", "rounding"],
+		filters={'is_earned_leave' : 1})
+	today = getdate()
+	divide_by_frequency = {"Yearly": 1, "Quarterly": 4, "Monthly": 12}
+	if e_leave_types:
+		for e_leave_type in e_leave_types:
+			leave_allocations = frappe.db.sql("""select name, employee, from_date, to_date from `tabLeave Allocation` where '{0}'
+				between from_date and to_date and docstatus=1 and leave_type='{1}'"""
+				.format(today, e_leave_type.name), as_dict=1)
+			for allocation in leave_allocations:
+				leave_policy = get_employee_leave_policy(allocation.employee)
+				if not leave_policy:
+					continue
+				if not e_leave_type.earned_leave_frequency == "Monthly":
+					if not check_frequency_hit(allocation.from_date, today, e_leave_type.earned_leave_frequency):
+						continue
+				annual_allocation = frappe.db.sql("""select annual_allocation from `tabLeave Policy Detail`
+					where parent=%s and leave_type=%s""", (leave_policy.name, e_leave_type.name))
+				if annual_allocation and annual_allocation[0]:
+					earned_leaves = flt(annual_allocation[0][0]) / divide_by_frequency[e_leave_type.earned_leave_frequency]
+					if e_leave_type.rounding == "0.5":
+						earned_leaves = round(earned_leaves * 2) / 2
+					else:
+						earned_leaves = round(earned_leaves)
+
+					allocated_leaves = frappe.db.get_value('Leave Allocation', allocation.name, 'total_leaves_allocated')
+					new_allocation = flt(allocated_leaves) + flt(earned_leaves)
+					new_allocation = new_allocation if new_allocation <= e_leave_type.max_leaves_allowed else e_leave_type.max_leaves_allowed
+					frappe.db.set_value('Leave Allocation', allocation.name, 'total_leaves_allocated', new_allocation)
+
+def check_frequency_hit(from_date, to_date, frequency):
+	'''Return True if current date matches frequency'''
+	from_dt = get_datetime(from_date)
+	to_dt = get_datetime(to_date)
+	from dateutil import relativedelta
+	rd = relativedelta.relativedelta(to_dt, from_dt)
+	months = rd.months
+	if frequency == "Quarterly":
+		if not months % 3:
+			return True
+	elif frequency == "Yearly":
+		if not months % 12:
+			return True
+	return False