feat : Leave type with partial payment (#23173)

* feat: Partially paid Leaves

* feat: some importatnt validation

* fix: requested changes

* fix: requested changes

* fix: travis, sider, codacy

* fix: changes requested

* test: Partially Paid Leaves
This commit is contained in:
Anurag Mishra 2020-11-25 16:00:15 +05:30 committed by GitHub
parent 90e33e53fd
commit f32cff1080
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 117 additions and 48 deletions

View File

@ -672,10 +672,10 @@
"oldfieldtype": "Date"
},
{
"depends_on": "eval:doc.status == \"Left\"",
"fieldname": "relieving_date",
"fieldtype": "Date",
"label": "Relieving Date",
"mandatory_depends_on": "eval:doc.status == \"Left\"",
"oldfieldname": "relieving_date",
"oldfieldtype": "Date"
},
@ -822,7 +822,7 @@
"idx": 24,
"image_field": "image",
"links": [],
"modified": "2020-10-06 15:58:23.805489",
"modified": "2020-10-16 14:41:10.580897",
"modified_by": "Administrator",
"module": "HR",
"name": "Employee",

View File

@ -130,8 +130,7 @@ class LeaveApplication(Document):
if self.status == "Approved":
for dt in daterange(getdate(self.from_date), getdate(self.to_date)):
date = dt.strftime("%Y-%m-%d")
status = "Half Day" if getdate(date) == getdate(self.half_day_date) else "On Leave"
status = "Half Day" if self.half_day_date and getdate(date) == getdate(self.half_day_date) else "On Leave"
attendance_name = frappe.db.exists('Attendance', dict(employee = self.employee,
attendance_date = date, docstatus = ('!=', 2)))
@ -293,7 +292,8 @@ class LeaveApplication(Document):
def set_half_day_date(self):
if self.from_date == self.to_date and self.half_day == 1:
self.half_day_date = self.from_date
elif self.half_day == 0:
if self.half_day == 0:
self.half_day_date = None
def notify_employee(self):

View File

@ -15,6 +15,8 @@
"column_break_3",
"is_carry_forward",
"is_lwp",
"is_ppl",
"fraction_of_daily_salary_per_leave",
"is_optional_leave",
"allow_negative",
"include_holiday",
@ -77,6 +79,7 @@
},
{
"default": "0",
"depends_on": "eval:doc.is_ppl == 0",
"fieldname": "is_lwp",
"fieldtype": "Check",
"label": "Is Leave Without Pay"
@ -183,12 +186,26 @@
{
"fieldname": "column_break_22",
"fieldtype": "Column Break"
},
{
"default": "0",
"depends_on": "eval:doc.is_lwp == 0",
"fieldname": "is_ppl",
"fieldtype": "Check",
"label": "Is Partially Paid Leave"
},
{
"depends_on": "eval:doc.is_ppl == 1",
"fieldname": "fraction_of_daily_salary_per_leave",
"fieldtype": "Float",
"label": "Fraction of Daily Salary per Leave",
"mandatory_depends_on": "eval:doc.is_ppl == 1"
}
],
"icon": "fa fa-flag",
"idx": 1,
"links": [],
"modified": "2019-12-12 12:48:37.780254",
"modified": "2020-08-26 14:04:54.318687",
"modified_by": "Administrator",
"module": "HR",
"name": "Leave Type",

View File

@ -21,3 +21,9 @@ class LeaveType(Document):
leave_allocation = [l['name'] for l in leave_allocation]
if leave_allocation:
frappe.throw(_('Leave application is linked with leave allocations {0}. Leave application cannot be set as leave without pay').format(", ".join(leave_allocation))) #nosec
if self.is_lwp and self.is_ppl:
frappe.throw(_("Leave Type can be either without pay or partial pay"))
if self.is_ppl and (self.fraction_of_daily_salary_per_leave < 0 or self.fraction_of_daily_salary_per_leave > 1):
frappe.throw(_("The fraction of Daily Salary per Leave should be between 0 and 1"))

View File

@ -18,9 +18,14 @@ def create_leave_type(**args):
"allow_encashment": args.allow_encashment or 0,
"is_earned_leave": args.is_earned_leave or 0,
"is_lwp": args.is_lwp or 0,
"is_ppl":args.is_ppl or 0,
"is_carry_forward": args.is_carry_forward or 0,
"expire_carry_forwarded_leaves_after_days": args.expire_carry_forwarded_leaves_after_days or 0,
"encashment_threshold_days": args.encashment_threshold_days or 5,
"earning_component": "Leave Encashment"
})
if leave_type.is_ppl:
leave_type.fraction_of_daily_salary_per_leave = args.fraction_of_daily_salary_per_leave or 0.5
return leave_type

View File

@ -13,12 +13,12 @@ frappe.ui.form.on("Salary Slip", {
];
});
frm.fields_dict["timesheets"].grid.get_field("time_sheet").get_query = function(){
frm.fields_dict["timesheets"].grid.get_field("time_sheet").get_query = function() {
return {
filters: {
employee: frm.doc.employee
}
}
};
};
frm.set_query("salary_component", "earnings", function() {
@ -26,7 +26,7 @@ frappe.ui.form.on("Salary Slip", {
filters: {
type: "earning"
}
}
};
});
frm.set_query("salary_component", "deductions", function() {
@ -34,18 +34,18 @@ frappe.ui.form.on("Salary Slip", {
filters: {
type: "deduction"
}
}
};
});
frm.set_query("employee", function() {
return{
return {
query: "erpnext.controllers.queries.employee_query"
}
};
});
},
start_date: function(frm){
if(frm.doc.start_date){
start_date: function(frm) {
if (frm.doc.start_date) {
frm.trigger("set_end_date");
}
},
@ -54,7 +54,7 @@ frappe.ui.form.on("Salary Slip", {
frm.events.get_emp_and_working_day_details(frm);
},
set_end_date: function(frm){
set_end_date: function(frm) {
frappe.call({
method: 'erpnext.payroll.doctype.payroll_entry.payroll_entry.get_end_date',
args: {
@ -66,22 +66,22 @@ frappe.ui.form.on("Salary Slip", {
frm.set_value('end_date', r.message.end_date);
}
}
})
});
},
company: function(frm) {
var company = locals[':Company'][frm.doc.company];
if(!frm.doc.letter_head && company.default_letter_head) {
if (!frm.doc.letter_head && company.default_letter_head) {
frm.set_value('letter_head', company.default_letter_head);
}
},
refresh: function(frm) {
frm.trigger("toggle_fields")
frm.trigger("toggle_fields");
var salary_detail_fields = ["formula", "abbr", "statistical_component", "variable_based_on_taxable_salary"];
cur_frm.fields_dict['earnings'].grid.set_column_disp(salary_detail_fields,false);
cur_frm.fields_dict['deductions'].grid.set_column_disp(salary_detail_fields,false);
cur_frm.fields_dict['earnings'].grid.set_column_disp(salary_detail_fields, false);
cur_frm.fields_dict['deductions'].grid.set_column_disp(salary_detail_fields, false);
},
salary_slip_based_on_timesheet: function(frm) {
@ -98,12 +98,12 @@ frappe.ui.form.on("Salary Slip", {
frm.events.get_emp_and_working_day_details(frm);
},
leave_without_pay: function(frm){
leave_without_pay: function(frm) {
if (frm.doc.employee && frm.doc.start_date && frm.doc.end_date) {
return frappe.call({
method: 'process_salary_based_on_working_days',
doc: frm.doc,
callback: function(r, rt) {
callback: function() {
frm.refresh();
}
});
@ -121,10 +121,10 @@ frappe.ui.form.on("Salary Slip", {
return frappe.call({
method: 'get_emp_and_working_day_details',
doc: frm.doc,
callback: function(r, rt) {
callback: function(r) {
frm.refresh();
if (r.message){
frm.fields_dict.absent_days.set_description("Unmarked Days is treated as "+ r.message +". You can can change this in " + frappe.utils.get_form_link("Payroll Settings", "Payroll Settings", true));
if (r.message[1] !== "Leave" && r.message[0]) {
frm.fields_dict.absent_days.set_description(__("Unmarked Days is treated as ")+ r.message[0] +__(". You can can change this in ") + frappe.utils.get_form_link("Payroll Settings", "Payroll Settings", true));
}
}
});
@ -141,7 +141,7 @@ frappe.ui.form.on('Salary Slip Timesheet', {
});
// calculate total working hours, earnings based on hourly wages and totals
var total_work_hours = function(frm, dt, dn) {
var total_work_hours = function(frm) {
var total_working_hours = 0.0;
$.each(frm.doc["timesheets"] || [], function(i, timesheet) {
total_working_hours += timesheet.working_hours;
@ -165,4 +165,4 @@ var total_work_hours = function(frm, dt, dn) {
frm.doc.rounded_total = Math.round(frm.doc.net_pay);
refresh_many(['net_pay', 'rounded_total']);
});
}
};

View File

@ -136,8 +136,8 @@ class SalarySlip(TransactionBase):
self.salary_slip_based_on_timesheet = self._salary_structure_doc.salary_slip_based_on_timesheet or 0
self.set_time_sheet()
self.pull_sal_struct()
consider_unmarked_attendance_as = frappe.db.get_value("Payroll Settings", None, "consider_unmarked_attendance_as") or "Present"
return consider_unmarked_attendance_as
payroll_based_on, consider_unmarked_attendance_as = frappe.db.get_value("Payroll Settings", None, ["payroll_based_on","consider_unmarked_attendance_as"])
return [payroll_based_on, consider_unmarked_attendance_as]
def set_time_sheet(self):
if self.salary_slip_based_on_timesheet:
@ -210,10 +210,10 @@ class SalarySlip(TransactionBase):
frappe.throw(_("Please set Payroll based on in Payroll settings"))
if payroll_based_on == "Attendance":
actual_lwp, absent = self.calculate_lwp_and_absent_days_based_on_attendance(holidays)
actual_lwp, absent = self.calculate_lwp_ppl_and_absent_days_based_on_attendance(holidays)
self.absent_days = absent
else:
actual_lwp = self.calculate_lwp_based_on_leave_application(holidays, working_days)
actual_lwp = self.calculate_lwp_or_ppl_based_on_leave_application(holidays, working_days)
if not lwp:
lwp = actual_lwp
@ -300,7 +300,7 @@ class SalarySlip(TransactionBase):
return holidays
def calculate_lwp_based_on_leave_application(self, holidays, working_days):
def calculate_lwp_or_ppl_based_on_leave_application(self, holidays, working_days):
lwp = 0
holidays = "','".join(holidays)
daily_wages_fraction_for_half_day = \
@ -311,10 +311,12 @@ class SalarySlip(TransactionBase):
leave = frappe.db.sql("""
SELECT t1.name,
CASE WHEN (t1.half_day_date = %(dt)s or t1.to_date = t1.from_date)
THEN t1.half_day else 0 END
THEN t1.half_day else 0 END,
t2.is_ppl,
t2.fraction_of_daily_salary_per_leave
FROM `tabLeave Application` t1, `tabLeave Type` t2
WHERE t2.name = t1.leave_type
AND t2.is_lwp = 1
AND (t2.is_lwp = 1 or t2.is_ppl = 1)
AND t1.docstatus = 1
AND t1.employee = %(employee)s
AND ifnull(t1.salary_slip, '') = ''
@ -327,19 +329,35 @@ class SalarySlip(TransactionBase):
""".format(holidays), {"employee": self.employee, "dt": dt})
if leave:
equivalent_lwp_count = 0
is_half_day_leave = cint(leave[0][1])
lwp += (1 - daily_wages_fraction_for_half_day) if is_half_day_leave else 1
is_partially_paid_leave = cint(leave[0][2])
fraction_of_daily_salary_per_leave = flt(leave[0][3])
equivalent_lwp_count = (1 - daily_wages_fraction_for_half_day) if is_half_day_leave else 1
if is_partially_paid_leave:
equivalent_lwp_count *= fraction_of_daily_salary_per_leave if fraction_of_daily_salary_per_leave else 1
lwp += equivalent_lwp_count
return lwp
def calculate_lwp_and_absent_days_based_on_attendance(self, holidays):
def calculate_lwp_ppl_and_absent_days_based_on_attendance(self, holidays):
lwp = 0
absent = 0
daily_wages_fraction_for_half_day = \
flt(frappe.db.get_value("Payroll Settings", None, "daily_wages_fraction_for_half_day")) or 0.5
lwp_leave_types = dict(frappe.get_all("Leave Type", {"is_lwp": 1}, ["name", "include_holiday"], as_list=1))
leave_types = frappe.get_all("Leave Type",
or_filters=[["is_ppl", "=", 1], ["is_lwp", "=", 1]],
fields =["name", "is_lwp", "is_ppl", "fraction_of_daily_salary_per_leave", "include_holiday"])
leave_type_map = {}
for leave_type in leave_types:
leave_type_map[leave_type.name] = leave_type
attendances = frappe.db.sql('''
SELECT attendance_date, status, leave_type
FROM `tabAttendance`
@ -351,21 +369,30 @@ class SalarySlip(TransactionBase):
''', values=(self.employee, self.start_date, self.end_date), as_dict=1)
for d in attendances:
if d.status in ('Half Day', 'On Leave') and d.leave_type and d.leave_type not in lwp_leave_types:
if d.status in ('Half Day', 'On Leave') and d.leave_type and d.leave_type not in leave_type_map.keys():
continue
if formatdate(d.attendance_date, "yyyy-mm-dd") in holidays:
if d.status == "Absent" or \
(d.leave_type and d.leave_type in lwp_leave_types and not lwp_leave_types[d.leave_type]):
(d.leave_type and d.leave_type in leave_type_map.keys() and not leave_type_map[d.leave_type]['include_holiday']):
continue
if d.leave_type:
fraction_of_daily_salary_per_leave = leave_type_map[d.leave_type]["fraction_of_daily_salary_per_leave"]
if d.status == "Half Day":
lwp += (1 - daily_wages_fraction_for_half_day)
elif d.status == "On Leave" and d.leave_type in lwp_leave_types:
lwp += 1
equivalent_lwp = (1 - daily_wages_fraction_for_half_day)
if d.leave_type in leave_type_map.keys() and leave_type_map[d.leave_type]["is_ppl"]:
equivalent_lwp *= fraction_of_daily_salary_per_leave if fraction_of_daily_salary_per_leave else 1
lwp += equivalent_lwp
elif d.status == "On Leave" and d.leave_type and d.leave_type in leave_type_map.keys():
equivalent_lwp = 1
if leave_type_map[d.leave_type]["is_ppl"]:
equivalent_lwp *= fraction_of_daily_salary_per_leave if fraction_of_daily_salary_per_leave else 1
lwp += equivalent_lwp
elif d.status == "Absent":
absent += 1
return lwp, absent
def add_earning_for_hourly_wages(self, doc, salary_component, amount):
@ -949,9 +976,8 @@ class SalarySlip(TransactionBase):
amounts = calculate_amounts(payment.loan, self.posting_date, "Regular Payment")
total_amount = amounts['interest_amount'] + amounts['payable_principal_amount']
if payment.total_payment > total_amount:
frappe.throw(_("""Row {0}: Paid amount {1} is greater than pending accrued amount {2}
against loan {3}""").format(payment.idx, frappe.bold(payment.total_payment),
frappe.bold(total_amount), frappe.bold(payment.loan)))
frappe.throw(_("Row {0}: Paid amount {1} is greater than pending accrued amount {2}against loan {3}").format(
payment.idx, frappe.bold(payment.total_payment),frappe.bold(total_amount), frappe.bold(payment.loan)))
self.total_interest_amount += payment.interest_amount
self.total_principal_amount += payment.principal_amount

View File

@ -13,6 +13,8 @@ from frappe.utils import getdate, nowdate, add_days, add_months, flt, get_first_
from erpnext.payroll.doctype.salary_structure.salary_structure import make_salary_slip
from erpnext.payroll.doctype.payroll_entry.payroll_entry import get_month_details
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
from erpnext.payroll.doctype.employee_tax_exemption_declaration.test_employee_tax_exemption_declaration \
import create_payroll_period, create_exemption_category
@ -93,14 +95,27 @@ class TestSalarySlip(unittest.TestCase):
make_leave_application(emp_id, first_sunday, add_days(first_sunday, 3), "Leave Without Pay")
leave_type_ppl = create_leave_type(leave_type_name="Test Partially Paid Leave", is_ppl = 1)
leave_type_ppl.save()
alloc = create_leave_allocation(
employee = emp_id, from_date = add_days(first_sunday, 4),
to_date = add_days(first_sunday, 10), new_leaves_allocated = 3,
leave_type = "Test Partially Paid Leave")
alloc.save()
alloc.submit()
#two day leave ppl with fraction_of_daily_salary_per_leave = 0.5 equivalent to single day lwp
make_leave_application(emp_id, add_days(first_sunday, 4), add_days(first_sunday, 5), "Test Partially Paid Leave")
ss = make_employee_salary_slip("test_for_attendance@salary.com", "Monthly")
self.assertEqual(ss.leave_without_pay, 3)
self.assertEqual(ss.leave_without_pay, 4)
days_in_month = no_of_days[0]
no_of_holidays = no_of_days[1]
self.assertEqual(ss.payment_days, days_in_month - no_of_holidays - 3)
self.assertEqual(ss.payment_days, days_in_month - no_of_holidays - 4)
#Gross pay calculation based on attendances
gross_pay = 78000 - ((78000 / (days_in_month - no_of_holidays)) * flt(ss.leave_without_pay))