fix: wrong payment days in salary slip for employees joining/leaving during mid payroll dates (#29082)

Co-authored-by: Rucha Mahabal <ruchamahabal2@gmail.com>
diff --git a/erpnext/hr/doctype/attendance/attendance.py b/erpnext/hr/doctype/attendance/attendance.py
index b1eaaf8..b1e373e 100644
--- a/erpnext/hr/doctype/attendance/attendance.py
+++ b/erpnext/hr/doctype/attendance/attendance.py
@@ -174,16 +174,22 @@
 def get_unmarked_days(employee, month, exclude_holidays=0):
 	import calendar
 	month_map = get_month_map()
-
 	today = get_datetime()
 
-	dates_of_month = ['{}-{}-{}'.format(today.year, month_map[month], r) for r in range(1, calendar.monthrange(today.year, month_map[month])[1] + 1)]
+	joining_date, relieving_date = frappe.get_cached_value("Employee", employee, ["date_of_joining", "relieving_date"])
+	start_day = 1
+	end_day = calendar.monthrange(today.year, month_map[month])[1] + 1
 
-	length = len(dates_of_month)
-	month_start, month_end = dates_of_month[0], dates_of_month[length-1]
+	if joining_date and joining_date.month == month_map[month]:
+		start_day = joining_date.day
 
+	if relieving_date and relieving_date.month == month_map[month]:
+		end_day = relieving_date.day + 1
 
-	records = frappe.get_all("Attendance", fields = ['attendance_date', 'employee'] , filters = [
+	dates_of_month = ['{}-{}-{}'.format(today.year, month_map[month], r) for r in range(start_day, end_day)]
+	month_start, month_end = dates_of_month[0], dates_of_month[-1]
+
+	records = frappe.get_all("Attendance", fields=['attendance_date', 'employee'], filters=[
 		["attendance_date", ">=", month_start],
 		["attendance_date", "<=", month_end],
 		["employee", "=", employee],
@@ -200,7 +206,7 @@
 
 	for date in dates_of_month:
 		date_time = get_datetime(date)
-		if today.day == date_time.day and today.month == date_time.month:
+		if today.day <= date_time.day and today.month <= date_time.month:
 			break
 		if date_time not in marked_days:
 			unmarked_days.append(date)
diff --git a/erpnext/hr/doctype/attendance/test_attendance.py b/erpnext/hr/doctype/attendance/test_attendance.py
index a770d70..118cc98 100644
--- a/erpnext/hr/doctype/attendance/test_attendance.py
+++ b/erpnext/hr/doctype/attendance/test_attendance.py
@@ -4,17 +4,104 @@
 import unittest
 
 import frappe
-from frappe.utils import nowdate
+from frappe.utils import add_days, get_first_day, getdate, nowdate
+
+from erpnext.hr.doctype.attendance.attendance import (
+	get_month_map,
+	get_unmarked_days,
+	mark_attendance,
+)
+from erpnext.hr.doctype.employee.test_employee import make_employee
+from erpnext.hr.doctype.leave_application.test_leave_application import get_first_sunday
 
 test_records = frappe.get_test_records('Attendance')
 
 class TestAttendance(unittest.TestCase):
 	def test_mark_absent(self):
-		from erpnext.hr.doctype.employee.test_employee import make_employee
 		employee = make_employee("test_mark_absent@example.com")
 		date = nowdate()
 		frappe.db.delete('Attendance', {'employee':employee, 'attendance_date':date})
-		from erpnext.hr.doctype.attendance.attendance import mark_attendance
 		attendance = mark_attendance(employee, date, 'Absent')
 		fetch_attendance = frappe.get_value('Attendance', {'employee':employee, 'attendance_date':date, 'status':'Absent'})
 		self.assertEqual(attendance, fetch_attendance)
+
+	def test_unmarked_days(self):
+		first_day = get_first_day(getdate())
+
+		employee = make_employee('test_unmarked_days@example.com', date_of_joining=add_days(first_day, -1))
+		frappe.db.delete('Attendance', {'employee': employee})
+
+		from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list
+		holiday_list = make_holiday_list()
+		frappe.db.set_value('Employee', employee, 'holiday_list', holiday_list)
+
+		first_sunday = get_first_sunday(holiday_list)
+		mark_attendance(employee, first_day, 'Present')
+		month_name = get_month_name(first_day)
+
+		unmarked_days = get_unmarked_days(employee, month_name)
+		unmarked_days = [getdate(date) for date in unmarked_days]
+
+		# attendance already marked for the day
+		self.assertNotIn(first_day, unmarked_days)
+		# attendance unmarked
+		self.assertIn(getdate(add_days(first_day, 1)), unmarked_days)
+		# holiday considered in unmarked days
+		self.assertIn(first_sunday, unmarked_days)
+
+	def test_unmarked_days_excluding_holidays(self):
+		first_day = get_first_day(getdate())
+
+		employee = make_employee('test_unmarked_days@example.com', date_of_joining=add_days(first_day, -1))
+		frappe.db.delete('Attendance', {'employee': employee})
+
+		from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list
+		holiday_list = make_holiday_list()
+		frappe.db.set_value('Employee', employee, 'holiday_list', holiday_list)
+
+		first_sunday = get_first_sunday(holiday_list)
+		mark_attendance(employee, first_day, 'Present')
+		month_name = get_month_name(first_day)
+
+		unmarked_days = get_unmarked_days(employee, month_name, exclude_holidays=True)
+		unmarked_days = [getdate(date) for date in unmarked_days]
+
+		# attendance already marked for the day
+		self.assertNotIn(first_day, unmarked_days)
+		# attendance unmarked
+		self.assertIn(getdate(add_days(first_day, 1)), unmarked_days)
+		# holidays not considered in unmarked days
+		self.assertNotIn(first_sunday, unmarked_days)
+
+	def test_unmarked_days_as_per_joining_and_relieving_dates(self):
+		first_day = get_first_day(getdate())
+
+		doj = add_days(first_day, 1)
+		relieving_date = add_days(first_day, 5)
+		employee = make_employee('test_unmarked_days_as_per_doj@example.com', date_of_joining=doj,
+			date_of_relieving=relieving_date)
+		frappe.db.delete('Attendance', {'employee': employee})
+
+		attendance_date = add_days(first_day, 2)
+		mark_attendance(employee, attendance_date, 'Present')
+		month_name = get_month_name(first_day)
+
+		unmarked_days = get_unmarked_days(employee, month_name)
+		unmarked_days = [getdate(date) for date in unmarked_days]
+
+		# attendance already marked for the day
+		self.assertNotIn(attendance_date, unmarked_days)
+		# date before doj not in unmarked days
+		self.assertNotIn(add_days(doj, -1), unmarked_days)
+		# date after relieving not in unmarked days
+		self.assertNotIn(add_days(relieving_date, 1), unmarked_days)
+
+	def tearDown(self):
+		frappe.db.rollback()
+
+
+def get_month_name(date):
+	month_number = date.month
+	for month, number in get_month_map().items():
+		if number == month_number:
+			return month
\ No newline at end of file
diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.py b/erpnext/payroll/doctype/salary_slip/salary_slip.py
index d2a3998..b44dbb9 100644
--- a/erpnext/payroll/doctype/salary_slip/salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py
@@ -307,28 +307,59 @@
 			if payroll_based_on == "Attendance":
 				self.payment_days -= flt(absent)
 
-			unmarked_days = self.get_unmarked_days()
 			consider_unmarked_attendance_as = frappe.db.get_value("Payroll Settings", None, "consider_unmarked_attendance_as") or "Present"
 
 			if payroll_based_on == "Attendance" and consider_unmarked_attendance_as =="Absent":
+				unmarked_days = self.get_unmarked_days(include_holidays_in_total_working_days)
 				self.absent_days += unmarked_days #will be treated as absent
 				self.payment_days -= unmarked_days
-				if include_holidays_in_total_working_days:
-					for holiday in holidays:
-						if not frappe.db.exists("Attendance", {"employee": self.employee, "attendance_date": holiday, "docstatus": 1 }):
-							self.payment_days += 1
 		else:
 			self.payment_days = 0
 
-	def get_unmarked_days(self):
-		marked_days = frappe.get_all("Attendance", filters = {
-					"attendance_date": ["between", [self.start_date, self.end_date]],
-					"employee": self.employee,
-					"docstatus": 1
-				}, fields = ["COUNT(*) as marked_days"])[0].marked_days
+	def get_unmarked_days(self, include_holidays_in_total_working_days):
+		unmarked_days = self.total_working_days
+		joining_date, relieving_date = frappe.get_cached_value("Employee", self.employee,
+			["date_of_joining", "relieving_date"])
+		start_date = self.start_date
+		end_date = self.end_date
 
-		return self.total_working_days - marked_days
+		if joining_date and (getdate(self.start_date) < joining_date <= getdate(self.end_date)):
+			start_date = joining_date
+			unmarked_days = self.get_unmarked_days_based_on_doj_or_relieving(unmarked_days,
+				include_holidays_in_total_working_days, self.start_date, joining_date)
 
+		if relieving_date and (getdate(self.start_date) <= relieving_date < getdate(self.end_date)):
+			end_date = relieving_date
+			unmarked_days = self.get_unmarked_days_based_on_doj_or_relieving(unmarked_days,
+				include_holidays_in_total_working_days, relieving_date, self.end_date)
+
+		# exclude days for which attendance has been marked
+		unmarked_days -= frappe.get_all("Attendance", filters = {
+			"attendance_date": ["between", [start_date, end_date]],
+			"employee": self.employee,
+			"docstatus": 1
+		}, fields = ["COUNT(*) as marked_days"])[0].marked_days
+
+		return unmarked_days
+
+	def get_unmarked_days_based_on_doj_or_relieving(self, unmarked_days,
+		include_holidays_in_total_working_days, start_date, end_date):
+		"""
+		Exclude days before DOJ or after
+		Relieving Date from unmarked days
+		"""
+		from erpnext.hr.doctype.employee.employee import is_holiday
+
+		if include_holidays_in_total_working_days:
+			unmarked_days -= date_diff(end_date, start_date)
+		else:
+			# exclude only if not holidays
+			for days in range(date_diff(end_date, start_date)):
+				date = add_days(end_date, -days)
+				if not is_holiday(self.employee, date):
+					unmarked_days -= 1
+
+		return unmarked_days
 
 	def get_payment_days(self, joining_date, relieving_date, include_holidays_in_total_working_days):
 		if not joining_date:
diff --git a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
index 6a5debf..fe15f2d 100644
--- a/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
+++ b/erpnext/payroll/doctype/salary_slip/test_salary_slip.py
@@ -7,10 +7,12 @@
 
 import frappe
 from frappe.model.document import Document
+from frappe.tests.utils import change_settings
 from frappe.utils import (
 	add_days,
 	add_months,
 	cstr,
+	date_diff,
 	flt,
 	get_first_day,
 	get_last_day,
@@ -21,6 +23,7 @@
 
 import erpnext
 from erpnext.accounts.utils import get_fiscal_year
+from erpnext.hr.doctype.attendance.attendance import mark_attendance
 from erpnext.hr.doctype.employee.test_employee import make_employee
 from erpnext.hr.doctype.leave_allocation.test_leave_allocation import create_leave_allocation
 from erpnext.hr.doctype.leave_type.test_leave_type import create_leave_type
@@ -37,17 +40,17 @@
 		setup_test()
 
 	def tearDown(self):
+		frappe.db.rollback()
 		frappe.db.set_value("Payroll Settings", None, "include_holidays_in_total_working_days", 0)
 		frappe.set_user("Administrator")
 
+	@change_settings("Payroll Settings", {
+		"payroll_based_on": "Attendance",
+		"daily_wages_fraction_for_half_day": 0.75
+	})
 	def test_payment_days_based_on_attendance(self):
-		from erpnext.hr.doctype.attendance.attendance import mark_attendance
 		no_of_days = self.get_no_of_days()
 
-		# Payroll based on attendance
-		frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Attendance")
-		frappe.db.set_value("Payroll Settings", None, "daily_wages_fraction_for_half_day", 0.75)
-
 		emp_id = make_employee("test_payment_days_based_on_attendance@salary.com")
 		frappe.db.set_value("Employee", emp_id, {"relieving_date": None, "status": "Active"})
 
@@ -85,14 +88,78 @@
 
 		self.assertEqual(ss.gross_pay, gross_pay)
 
-		frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Leave")
+	@change_settings("Payroll Settings", {
+		"payroll_based_on": "Attendance",
+		"consider_unmarked_attendance_as": "Absent",
+		"include_holidays_in_total_working_days": True
+	})
+	def test_payment_days_for_mid_joinee_including_holidays(self):
+		from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday
 
+		no_of_days = self.get_no_of_days()
+		month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate())
+
+		new_emp_id = make_employee("test_payment_days_based_on_joining_date@salary.com")
+		joining_date, relieving_date = add_days(month_start_date, 3), add_days(month_end_date, -5)
+		frappe.db.set_value("Employee", new_emp_id, {
+			"date_of_joining": joining_date,
+			"relieving_date": relieving_date,
+			"status": "Left"
+		})
+
+		holidays = 0
+
+		for days in range(date_diff(relieving_date, joining_date) + 1):
+			date = add_days(joining_date, days)
+			if not is_holiday("Salary Slip Test Holiday List", date):
+				mark_attendance(new_emp_id, date, 'Present', ignore_validate=True)
+			else:
+				holidays += 1
+
+		new_ss = make_employee_salary_slip("test_payment_days_based_on_joining_date@salary.com", "Monthly", "Test Payment Based On Attendence")
+
+		self.assertEqual(new_ss.total_working_days, no_of_days[0])
+		self.assertEqual(new_ss.payment_days, no_of_days[0] - holidays - 8)
+
+	@change_settings("Payroll Settings", {
+		"payroll_based_on": "Attendance",
+		"consider_unmarked_attendance_as": "Absent",
+		"include_holidays_in_total_working_days": False
+	})
+	def test_payment_days_for_mid_joinee_excluding_holidays(self):
+		from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday
+
+		no_of_days = self.get_no_of_days()
+		month_start_date, month_end_date = get_first_day(nowdate()), get_last_day(nowdate())
+
+		new_emp_id = make_employee("test_payment_days_based_on_joining_date@salary.com")
+		joining_date, relieving_date = add_days(month_start_date, 3), add_days(month_end_date, -5)
+		frappe.db.set_value("Employee", new_emp_id, {
+			"date_of_joining": joining_date,
+			"relieving_date": relieving_date,
+			"status": "Left"
+		})
+
+		holidays = 0
+
+		for days in range(date_diff(relieving_date, joining_date) + 1):
+			date = add_days(joining_date, days)
+			if not is_holiday("Salary Slip Test Holiday List", date):
+				mark_attendance(new_emp_id, date, 'Present', ignore_validate=True)
+			else:
+				holidays += 1
+
+		new_ss = make_employee_salary_slip("test_payment_days_based_on_joining_date@salary.com", "Monthly", "Test Payment Based On Attendence")
+
+		self.assertEqual(new_ss.total_working_days, no_of_days[0] - no_of_days[1])
+		self.assertEqual(new_ss.payment_days, no_of_days[0] - holidays - 8)
+
+	@change_settings("Payroll Settings", {
+		"payroll_based_on": "Leave"
+	})
 	def test_payment_days_based_on_leave_application(self):
 		no_of_days = self.get_no_of_days()
 
-		# Payroll based on attendance
-		frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Leave")
-
 		emp_id = make_employee("test_payment_days_based_on_leave_application@salary.com")
 		frappe.db.set_value("Employee", emp_id, {"relieving_date": None, "status": "Active"})
 
@@ -133,8 +200,9 @@
 
 		self.assertEqual(ss.payment_days, days_in_month - no_of_holidays - 4)
 
-		frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Leave")
-
+	@change_settings("Payroll Settings", {
+		"payroll_based_on": "Attendance"
+	})
 	def test_payment_days_in_salary_slip_based_on_timesheet(self):
 		from erpnext.hr.doctype.attendance.attendance import mark_attendance
 		from erpnext.projects.doctype.timesheet.test_timesheet import (
@@ -145,9 +213,6 @@
 			make_salary_slip as make_salary_slip_for_timesheet,
 		)
 
-		# Payroll based on attendance
-		frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Attendance")
-
 		emp = make_employee("test_employee_timesheet@salary.com", company="_Test Company", holiday_list="Salary Slip Test Holiday List")
 		frappe.db.set_value("Employee", emp, {"relieving_date": None, "status": "Active"})
 
@@ -185,17 +250,15 @@
 
 		self.assertEqual(salary_slip.gross_pay, flt(gross_pay, 2))
 
-		frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Leave")
-
+	@change_settings("Payroll Settings", {
+		"payroll_based_on": "Attendance"
+	})
 	def test_component_amount_dependent_on_another_payment_days_based_component(self):
 		from erpnext.hr.doctype.attendance.attendance import mark_attendance
 		from erpnext.payroll.doctype.salary_structure.test_salary_structure import (
 			create_salary_structure_assignment,
 		)
 
-		# Payroll based on attendance
-		frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Attendance")
-
 		salary_structure = make_salary_structure_for_payment_days_based_component_dependency()
 		employee = make_employee("test_payment_days_based_component@salary.com", company="_Test Company")
 
@@ -238,11 +301,12 @@
 		expected_amount = flt((flt(ss.gross_pay) - payment_days_based_comp_amount) * 0.12, precision)
 
 		self.assertEqual(actual_amount, expected_amount)
-		frappe.db.set_value("Payroll Settings", None, "payroll_based_on", "Leave")
 
+	@change_settings("Payroll Settings", {
+		"include_holidays_in_total_working_days": 1
+	})
 	def test_salary_slip_with_holidays_included(self):
 		no_of_days = self.get_no_of_days()
-		frappe.db.set_value("Payroll Settings", None, "include_holidays_in_total_working_days", 1)
 		make_employee("test_salary_slip_with_holidays_included@salary.com")
 		frappe.db.set_value("Employee", frappe.get_value("Employee",
 			{"employee_name":"test_salary_slip_with_holidays_included@salary.com"}, "name"), "relieving_date", None)
@@ -256,9 +320,11 @@
 		self.assertEqual(ss.earnings[1].amount, 3000)
 		self.assertEqual(ss.gross_pay, 78000)
 
+	@change_settings("Payroll Settings", {
+		"include_holidays_in_total_working_days": 0
+	})
 	def test_salary_slip_with_holidays_excluded(self):
 		no_of_days = self.get_no_of_days()
-		frappe.db.set_value("Payroll Settings", None, "include_holidays_in_total_working_days", 0)
 		make_employee("test_salary_slip_with_holidays_excluded@salary.com")
 		frappe.db.set_value("Employee", frappe.get_value("Employee",
 			{"employee_name":"test_salary_slip_with_holidays_excluded@salary.com"}, "name"), "relieving_date", None)
@@ -273,14 +339,15 @@
 		self.assertEqual(ss.earnings[1].amount, 3000)
 		self.assertEqual(ss.gross_pay, 78000)
 
+	@change_settings("Payroll Settings", {
+		"include_holidays_in_total_working_days": 1
+	})
 	def test_payment_days(self):
 		from erpnext.payroll.doctype.salary_structure.test_salary_structure import (
 			create_salary_structure_assignment,
 		)
 
 		no_of_days = self.get_no_of_days()
-		# Holidays not included in working days
-		frappe.db.set_value("Payroll Settings", None, "include_holidays_in_total_working_days", 1)
 
 		# set joinng date in the same month
 		employee = make_employee("test_payment_days@salary.com")
@@ -338,11 +405,12 @@
 		frappe.set_user("test_employee_salary_slip_read_permission@salary.com")
 		self.assertTrue(salary_slip_test_employee.has_permission("read"))
 
+	@change_settings("Payroll Settings", {
+		"email_salary_slip_to_employee": 1
+	})
 	def test_email_salary_slip(self):
 		frappe.db.sql("delete from `tabEmail Queue`")
 
-		frappe.db.set_value("Payroll Settings", None, "email_salary_slip_to_employee", 1)
-
 		make_employee("test_email_salary_slip@salary.com")
 		ss = make_employee_salary_slip("test_email_salary_slip@salary.com", "Monthly", "Test Salary Slip Email")
 		ss.company = "_Test Company"