diff --git a/hrms/hr/doctype/attendance/attendance.js b/hrms/hr/doctype/attendance/attendance.js index 413ab523be..2c71757b11 100644 --- a/hrms/hr/doctype/attendance/attendance.js +++ b/hrms/hr/doctype/attendance/attendance.js @@ -13,4 +13,23 @@ frappe.ui.form.on("Attendance", { }; }); }, + employee: function (frm) { + if (frm.doc.employee && frm.doc.attendance_date) { + frm.events.set_shift(frm); + } + }, + set_shift: function (frm) { + frappe.call({ + method: "hrms.hr.doctype.attendance.attendance.get_shift_type", + args: { + employee: frm.doc.employee, + attendance_date: frm.doc.attendance_date, + }, + callback: function (r) { + if (r.message) { + frm.set_value("shift", r.message); + } + }, + }); + }, }); diff --git a/hrms/hr/doctype/attendance/attendance.json b/hrms/hr/doctype/attendance/attendance.json index 1da67e0f61..12bee14038 100644 --- a/hrms/hr/doctype/attendance/attendance.json +++ b/hrms/hr/doctype/attendance/attendance.json @@ -27,7 +27,13 @@ "column_break_18", "late_entry", "early_exit", - "amended_from" + "amended_from", + "overtime_section", + "overtime_duration", + "overtime_type", + "column_break_idku", + "standard_working_hours", + "actual_overtime_duration" ], "fields": [ { @@ -201,13 +207,50 @@ { "fieldname": "column_break_18", "fieldtype": "Column Break" + }, + { + "fieldname": "overtime_section", + "fieldtype": "Section Break", + "label": "Overtime" + }, + { + "default": "0", + "description": "Based on \"Maximum Overtime Hours Allowed\" in Overtime Type", + "fieldname": "overtime_duration", + "fieldtype": "Time", + "hide_days": 1, + "label": "Overtime Duration" + }, + { + "fetch_from": "shift.overtime_type", + "fieldname": "overtime_type", + "fieldtype": "Link", + "label": "Overtime Type", + "options": "Overtime Type" + }, + { + "fieldname": "column_break_idku", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "standard_working_hours", + "fieldtype": "Time", + "label": "Standard Working Hours" + }, + { + "default": "0", + "fieldname": "actual_overtime_duration", + "fieldtype": "Time", + "label": "Actual Overtime Duration", + "read_only": 1 } ], "icon": "fa fa-ok", "idx": 1, "is_submittable": 1, "links": [], - "modified": "2024-04-05 20:55:02.905452", + "modified": "2024-10-27 16:33:17.605504", "modified_by": "Administrator", "module": "HR", "name": "Attendance", diff --git a/hrms/hr/doctype/attendance/attendance.py b/hrms/hr/doctype/attendance/attendance.py index cd94b2476a..1125721d19 100644 --- a/hrms/hr/doctype/attendance/attendance.py +++ b/hrms/hr/doctype/attendance/attendance.py @@ -22,6 +22,7 @@ get_holidays_for_employee, validate_active_employee, ) +from hrms.payroll.doctype.salary_slip.salary_slip import convert_str_time_to_hours class DuplicateAttendanceError(frappe.ValidationError): @@ -43,10 +44,23 @@ def validate(self): self.validate_overlapping_shift_attendance() self.validate_employee_status() self.check_leave_record() + self.validate_overtime_duration() def on_cancel(self): self.unlink_attendance_from_checkins() + def validate_overtime_duration(self): + if self.overtime_type: + maximum_overtime_hours = frappe.db.get_value( + "Overtime Type", self.overtime_type, "maximum_overtime_hours_allowed" + ) + self.actual_overtime_duration = self.overtime_duration + if not maximum_overtime_hours: + return + overtime_duration_in_hours = convert_str_time_to_hours(self.overtime_duration) + if overtime_duration_in_hours > maximum_overtime_hours: + self.overtime_duration = str(maximum_overtime_hours) + ":00:00" + def validate_attendance_date(self): date_of_joining = frappe.db.get_value("Employee", self.employee, "date_of_joining") @@ -226,6 +240,27 @@ def unlink_attendance_from_checkins(self): ) +@frappe.whitelist() +def get_shift_type(employee, attendance_date): + ShiftAssignment = frappe.qb.DocType("Shift Assignment") + + shift_assignment = ( + frappe.qb.from_(ShiftAssignment) + .select(ShiftAssignment.name, ShiftAssignment.shift_type) + .where(ShiftAssignment.docstatus == 1) + .where(ShiftAssignment.employee == employee) + .where(ShiftAssignment.start_date <= attendance_date) + .where((ShiftAssignment.end_date >= attendance_date) | (ShiftAssignment.end_date.isnull())) + .where(ShiftAssignment.status == "Active") + ).run(as_dict=1) + + if len(shift_assignment): + shift = shift_assignment[0].shift_type + else: + shift = frappe.db.get_value("Employee", employee, "default_shift") + return shift + + @frappe.whitelist() def get_events(start, end, filters=None): from frappe.desk.reportview import get_filters_cond diff --git a/hrms/hr/doctype/employee_checkin/employee_checkin.py b/hrms/hr/doctype/employee_checkin/employee_checkin.py index ec3792dd1a..57e8bfa67f 100644 --- a/hrms/hr/doctype/employee_checkin/employee_checkin.py +++ b/hrms/hr/doctype/employee_checkin/employee_checkin.py @@ -2,6 +2,9 @@ # For license information, please see license.txt +from datetime import datetime +from typing import Union + import frappe from frappe import _ from frappe.model.document import Document @@ -202,6 +205,29 @@ def mark_attendance_and_link_log( try: frappe.db.savepoint("attendance_creation") attendance = frappe.new_doc("Attendance") + if shift: + shift_type_overtime = frappe.db.get_value( + doctype="Shift Type", + filters={"name": shift}, + fieldname=["overtime_type", "allow_overtime", "start_time", "end_time"], + as_dict=True, + ) + if shift_type_overtime.allow_overtime: + standard_working_hours = calculate_time_difference( + shift_type_overtime.start_time, shift_type_overtime.end_time + ) + total_working_duration = calculate_time_difference(in_time, out_time) + if total_working_duration > standard_working_hours: + overtime_duration = calculate_time_difference( + standard_working_hours, total_working_duration + ) + attendance.update( + { + "overtime_type": shift_type_overtime.overtime_type, + "standard_working_hours": standard_working_hours, + "overtime_duration": overtime_duration, + } + ) attendance.update( { "doctype": "Attendance", @@ -340,3 +366,38 @@ def update_attendance_in_checkins(log_names: list, attendance_id: str): .set("attendance", attendance_id) .where(EmployeeCheckin.name.isin(log_names)) ).run() + + +from datetime import timedelta + + +def convert_to_timedelta(input_value: str | timedelta) -> timedelta: + """ + Converts a string in the format 'HH:MM:SS' + """ + if isinstance(input_value, str): + # If the input is a string, parse it into a datetime object + time_format = "%H:%M:%S" + time_obj = datetime.strptime(input_value, time_format) + # Convert datetime to timedelta (we only care about the time) + return timedelta(hours=time_obj.hour, minutes=time_obj.minute, seconds=time_obj.second) + + return input_value + + +def calculate_time_difference(start_time, end_time): + """ + Converts inputs to timedelta, finds the difference between start and end times. + """ + # Convert both start and end times to timedelta + start_time_delta = convert_to_timedelta(start_time) + end_time_delta = convert_to_timedelta(end_time) + + # Calculate the time difference + time_difference = end_time_delta - start_time_delta + + # Return the difference only if it's positive + if time_difference.total_seconds() > 0: + return time_difference + else: + return timedelta(0) diff --git a/hrms/hr/doctype/employee_checkin/test_employee_checkin.py b/hrms/hr/doctype/employee_checkin/test_employee_checkin.py index aa532eb33b..0c89f614f2 100644 --- a/hrms/hr/doctype/employee_checkin/test_employee_checkin.py +++ b/hrms/hr/doctype/employee_checkin/test_employee_checkin.py @@ -591,7 +591,7 @@ def make_n_checkins(employee, n, hours_to_reverse=1): return logs -def make_checkin(employee, time=None, latitude=None, longitude=None): +def make_checkin(employee, time=None, latitude=None, longitude=None, log_type="IN"): if not time: time = now_datetime() @@ -601,7 +601,7 @@ def make_checkin(employee, time=None, latitude=None, longitude=None): "employee": employee, "time": time, "device_id": "device1", - "log_type": "IN", + "log_type": log_type, "latitude": latitude, "longitude": longitude, } diff --git a/hrms/hr/doctype/overtime_details/__init__.py b/hrms/hr/doctype/overtime_details/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hrms/hr/doctype/overtime_details/overtime_details.json b/hrms/hr/doctype/overtime_details/overtime_details.json new file mode 100644 index 0000000000..bae6004883 --- /dev/null +++ b/hrms/hr/doctype/overtime_details/overtime_details.json @@ -0,0 +1,78 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-10-15 16:36:22.056743", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "reference_document_type", + "reference_document", + "date", + "column_break_ilza", + "overtime_type", + "overtime_duration", + "standard_working_hours" + ], + "fields": [ + { + "fieldname": "reference_document_type", + "fieldtype": "Link", + "label": "Reference Document Type", + "options": "DocType", + "reqd": 1 + }, + { + "fieldname": "reference_document", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "Reference Document", + "options": "reference_document_type", + "reqd": 1 + }, + { + "fieldname": "date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Date", + "reqd": 1 + }, + { + "fieldname": "column_break_ilza", + "fieldtype": "Column Break" + }, + { + "fieldname": "overtime_type", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Overtime Type", + "options": "Overtime Type", + "reqd": 1 + }, + { + "fieldname": "overtime_duration", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Overtime Duration", + "reqd": 1 + }, + { + "default": "0", + "fieldname": "standard_working_hours", + "fieldtype": "Data", + "label": "Standard Working Hours" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2024-10-27 19:40:06.521102", + "modified_by": "Administrator", + "module": "HR", + "name": "Overtime Details", + "owner": "Administrator", + "permissions": [], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/hrms/hr/doctype/overtime_details/overtime_details.py b/hrms/hr/doctype/overtime_details/overtime_details.py new file mode 100644 index 0000000000..8876546130 --- /dev/null +++ b/hrms/hr/doctype/overtime_details/overtime_details.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class OvertimeDetails(Document): + pass diff --git a/hrms/hr/doctype/overtime_salary_component/__init__.py b/hrms/hr/doctype/overtime_salary_component/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hrms/hr/doctype/overtime_salary_component/overtime_salary_component.json b/hrms/hr/doctype/overtime_salary_component/overtime_salary_component.json new file mode 100644 index 0000000000..18d598919f --- /dev/null +++ b/hrms/hr/doctype/overtime_salary_component/overtime_salary_component.json @@ -0,0 +1,33 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-10-13 14:00:52.211875", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "salary_component" + ], + "fields": [ + { + "fieldname": "salary_component", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Salary Component", + "options": "Salary Component", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2024-10-13 14:01:49.333952", + "modified_by": "Administrator", + "module": "HR", + "name": "Overtime Salary Component", + "owner": "Administrator", + "permissions": [], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/hrms/hr/doctype/overtime_salary_component/overtime_salary_component.py b/hrms/hr/doctype/overtime_salary_component/overtime_salary_component.py new file mode 100644 index 0000000000..a55e5564c9 --- /dev/null +++ b/hrms/hr/doctype/overtime_salary_component/overtime_salary_component.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class OvertimeSalaryComponent(Document): + pass diff --git a/hrms/hr/doctype/overtime_slip/__init__.py b/hrms/hr/doctype/overtime_slip/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hrms/hr/doctype/overtime_slip/overtime_slip.js b/hrms/hr/doctype/overtime_slip/overtime_slip.js new file mode 100644 index 0000000000..8c061d69f3 --- /dev/null +++ b/hrms/hr/doctype/overtime_slip/overtime_slip.js @@ -0,0 +1,41 @@ +// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on("Overtime Slip", { + employee(frm) { + if (frm.doc.employee) { + frm.events.set_frequency_and_dates(frm).then(() => { + frm.events.get_emp_details_and_overtime_duration(frm); + }); + } + }, + from_date: function (frm) { + if (frm.doc.employee && frm.doc.from_date) { + frm.events.set_frequency_and_dates(frm).then(() => { + frm.events.get_emp_details_and_overtime_duration(frm); + }); + } + }, + set_frequency_and_dates: function (frm) { + if (frm.doc.employee) { + return frappe.call({ + method: "get_frequency_and_dates", + doc: frm.doc, + callback: function () { + frm.refresh(); + }, + }); + } + }, + get_emp_details_and_overtime_duration: function (frm) { + if (frm.doc.employee) { + return frappe.call({ + method: "get_emp_and_overtime_details", + doc: frm.doc, + callback: function () { + frm.refresh(); + }, + }); + } + }, +}); diff --git a/hrms/hr/doctype/overtime_slip/overtime_slip.json b/hrms/hr/doctype/overtime_slip/overtime_slip.json new file mode 100644 index 0000000000..9d8d7b0b73 --- /dev/null +++ b/hrms/hr/doctype/overtime_slip/overtime_slip.json @@ -0,0 +1,185 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "HR-OT-SLIP-.#####", + "creation": "2024-10-15 16:19:55.229439", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "section_break_wdfp", + "posting_date", + "amended_from", + "employee", + "employee_name", + "column_break_xsxd", + "department", + "company", + "status", + "section_break_fvyh", + "from_date", + "to_date", + "salary_slip", + "column_break_sdpb", + "payroll_frequency", + "total_overtime_duration", + "section_break_dzua", + "overtime_details" + ], + "fields": [ + { + "fieldname": "section_break_wdfp", + "fieldtype": "Section Break" + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Overtime Slip", + "print_hide": 1, + "read_only": 1, + "search_index": 1 + }, + { + "fieldname": "posting_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Posting Date", + "options": "Today", + "reqd": 1 + }, + { + "fieldname": "employee", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Employee", + "options": "Employee", + "reqd": 1 + }, + { + "fetch_from": "employee.employee_name", + "fieldname": "employee_name", + "fieldtype": "Data", + "label": "Employee Name" + }, + { + "fieldname": "column_break_xsxd", + "fieldtype": "Column Break" + }, + { + "fetch_from": "employee.department", + "fieldname": "department", + "fieldtype": "Link", + "label": "Department", + "options": "Department" + }, + { + "fetch_from": "employee.company", + "fieldname": "company", + "fieldtype": "Link", + "label": "Company", + "options": "Company", + "reqd": 1 + }, + { + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "label": "Status", + "options": "Pending\nApproved\nRejected" + }, + { + "fieldname": "section_break_fvyh", + "fieldtype": "Section Break" + }, + { + "fieldname": "from_date", + "fieldtype": "Date", + "label": "From Date" + }, + { + "fieldname": "to_date", + "fieldtype": "Date", + "label": "To Date" + }, + { + "fieldname": "column_break_sdpb", + "fieldtype": "Column Break" + }, + { + "fieldname": "payroll_frequency", + "fieldtype": "Select", + "label": "Payroll Frequency", + "options": "\nMonthly\nFortnightly\nBimonthly\nWeekly\nDaily" + }, + { + "fieldname": "section_break_dzua", + "fieldtype": "Section Break" + }, + { + "fieldname": "overtime_details", + "fieldtype": "Table", + "label": "Overtime Details", + "options": "Overtime Details", + "reqd": 1 + }, + { + "fieldname": "total_overtime_duration", + "fieldtype": "Data", + "label": "Total Overtime Duration" + }, + { + "allow_on_submit": 1, + "fieldname": "salary_slip", + "fieldtype": "Link", + "ignore_user_permissions": 1, + "label": "Salary Slip", + "options": "Salary Slip", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "is_submittable": 1, + "links": [], + "modified": "2024-10-20 15:53:47.470320", + "modified_by": "Administrator", + "module": "HR", + "name": "Overtime Slip", + "naming_rule": "Expression (old style)", + "owner": "Administrator", + "permissions": [ + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "select": 1, + "share": 1, + "submit": 1, + "write": 1 + }, + { + "cancel": 1, + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "HR Manager", + "select": 1, + "share": 1, + "submit": 1, + "write": 1 + } + ], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/hrms/hr/doctype/overtime_slip/overtime_slip.py b/hrms/hr/doctype/overtime_slip/overtime_slip.py new file mode 100644 index 0000000000..ae14119c6d --- /dev/null +++ b/hrms/hr/doctype/overtime_slip/overtime_slip.py @@ -0,0 +1,116 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from datetime import timedelta +from email.utils import formatdate + +import frappe +from frappe import _, bold +from frappe.model.docstatus import DocStatus +from frappe.model.document import Document +from frappe.utils.data import get_link_to_form, getdate + +from hrms.payroll.doctype.payroll_entry.payroll_entry import get_start_end_dates +from hrms.payroll.doctype.salary_structure_assignment.salary_structure_assignment import ( + get_assigned_salary_structure, +) + + +class OvertimeSlip(Document): + def validate(self): + if not (self.from_date or self.to_date or self.payroll_frequency): + self.get_frequency_and_dates() + + self.validate_overlap() + if self.from_date >= self.to_date: + frappe.throw(_("From date can not be greater than To date")) + + if not len(self.overtime_details): + self.get_emp_and_overtime_details() + + def validate_overlap(self): + overtime_slips = frappe.db.get_all( + "Overtime Slip", + filters={ + "docstatus": ("<", 2), + "employee": self.employee, + "to_date": (">=", self.from_date), + "from_date": ("<=", self.to_date), + "name": ("!=", self.name), + }, + ) + if len(overtime_slips): + form_link = get_link_to_form("Overtime Slip", overtime_slips[0].name) + msg = _("Overtime Slip:{0} has been created between {1} and {1}").format( + bold(form_link), bold(formatdate(self.from_date)), bold(formatdate(self.to_date)) + ) + frappe.throw(msg) + + def on_submit(self): + if self.status == "Pending": + frappe.throw(_("Overtime Slip with Status 'Approved' or 'Rejected' are allowed for Submission")) + + @frappe.whitelist() + def get_frequency_and_dates(self): + date = self.from_date or self.posting_date + + salary_structure = get_assigned_salary_structure(self.employee, date) + if salary_structure: + payroll_frequency = frappe.db.get_value("Salary Structure", salary_structure, "payroll_frequency") + date_details = get_start_end_dates( + payroll_frequency, date, frappe.db.get_value("Employee", self.employee, "company") + ) + self.from_date = date_details.start_date + self.to_date = date_details.end_date + self.payroll_frequency = payroll_frequency + else: + frappe.throw(_("No Salary Structure Assignment found for Employee: {0}").format(self.employee)) + + @frappe.whitelist() + def get_emp_and_overtime_details(self): + records = self.get_attendance_record() + if len(records): + self.create_overtime_details_row_for_attendance(records) + if len(self.overtime_details): + self.total_overtime_duration = timedelta() + for detail in self.overtime_details: + if detail.overtime_duration is not None: + self.total_overtime_duration += detail.overtime_duration + + def create_overtime_details_row_for_attendance(self, records): + self.overtime_details = [] + for record in records: + if record.overtime_duration: + self.append( + "overtime_details", + { + "reference_document_type": "Attendance", + "reference_document": record.name, + "date": record.attendance_date, + "overtime_type": record.overtime_type, + "overtime_duration": record.overtime_duration, + "standard_working_hours": record.standard_working_hours, + }, + ) + + def get_attendance_record(self): + records = [] + if self.from_date and self.to_date: + records = frappe.get_all( + "Attendance", + fields=[ + "overtime_duration", + "name", + "attendance_date", + "overtime_type", + "standard_working_hours", + ], + filters={ + "employee": self.employee, + "docstatus": DocStatus.submitted(), + "attendance_date": ("between", [getdate(self.from_date), getdate(self.to_date)]), + "status": "Present", + "overtime_type": ["!=", ""], + }, + ) + return records diff --git a/hrms/hr/doctype/overtime_slip/test_overtime_slip.py b/hrms/hr/doctype/overtime_slip/test_overtime_slip.py new file mode 100644 index 0000000000..2b5f1723b3 --- /dev/null +++ b/hrms/hr/doctype/overtime_slip/test_overtime_slip.py @@ -0,0 +1,132 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from datetime import timedelta + +import frappe +from frappe.tests import IntegrationTestCase, UnitTestCase +from frappe.utils.data import add_days, get_datetime, get_first_day, nowdate, today + +from erpnext.setup.doctype.employee.test_employee import make_employee + +from hrms.hr.doctype.employee_checkin.test_employee_checkin import make_checkin +from hrms.hr.doctype.overtime_type.test_overtime_type import create_overtime_type +from hrms.hr.doctype.shift_type.test_shift_type import make_shift_assignment, setup_shift_type +from hrms.payroll.doctype.salary_slip.test_salary_slip import clear_cache +from hrms.payroll.doctype.salary_structure.test_salary_structure import ( + make_salary_structure, +) + +# On IntegrationTestCase, the doctype test records and all +# link-field test record depdendencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class TestOvertimeSlip(UnitTestCase): + def setUp(self): + for doctype in [ + "Overtime Type", + "Overtime Slip", + "Attendance", + "Employee Checkin", + "Shift Type", + "Shift Assignment", + ]: + frappe.db.sql(f"DELETE FROM `tab{doctype}`") + clear_cache() + + def test_create_overtime_slip(self): + if not frappe.db.exists("Company", "_Test Company"): + company = frappe.new_doc("Company") + company.company_name = "_Test Company" + company.abbr = "_TC" + company.default_currency = "INR" + company.country = "India" + company.insert() + + shift_type = setup_shift_type() + + employee = make_employee("test_overtime_slipn@example.com", company="_Test Company") + overtime_type = create_overtime_type(employee=employee) + + shift_type.allow_overtime = 1 + shift_type.overtime_type = overtime_type.name + shift_type.save() + + make_shift_assignment(shift_type.name, employee, get_first_day(nowdate())) + make_salary_structure( + "Test Overtime Salary Slip", + "Monthly", + employee=employee, + company="_Test Company", + ) + frappe.db.set_value("Employee", employee, "default_shift", shift_type.name) + + checkin = make_checkin(employee, time=get_datetime(today()) + timedelta(hours=8), log_type="IN") + checkout = make_checkin(employee, time=get_datetime(today()) + timedelta(hours=13), log_type="OUT") + self.assertEqual(checkin.shift, shift_type.name) + self.assertEqual(checkout.shift, shift_type.name) + + shift_type.reload() + shift_type.process_auto_attendance() + checkin.reload() + + attendance_records = frappe.get_all( + "Attendance", + filters={"shift": shift_type.name, "status": "Present"}, + fields=["name", "overtime_duration", "overtime_type", "attendance_date"], + ) + + records = {} + for record in attendance_records: + records[record.name] = { + "overtime_duration": record.overtime_duration, + "overtime_type": record.overtime_type, + "attendance_date": record.attendance_date, + } + + slip = create_overtime_slip(employee) + + for detail in slip.overtime_details: + self.assertIn(detail.reference_document, records.keys()) + if detail.reference_document in records.keys(): + self.assertEqual( + detail.overtime_duration, records[detail.reference_document]["overtime_duration"] + ) + self.assertEqual(str(detail.date), str(records[detail.reference_document]["attendance_date"])) + + +def create_overtime_slip(employee): + slip = frappe.new_doc("Overtime Slip") + slip.employee = employee + slip.posting_date = today() + slip.overtime_details = [] + + slip.save() + return slip + + +def create_attendance_records_for_overtime(employee, overtime_type): + records = {} + for x in range(2): + attendance = frappe.new_doc("Attendance") + attendance.employee = employee + attendance.status = "Present" + attendance.attendance_date = add_days(today(), -(x)) + attendance.overtime_type = overtime_type + attendance.overtime_duration = "02:00:00" + attendance.standard_working_hours = timedelta(hours=4) + + attendance.save() + attendance.submit() + + records[attendance.name] = { + "overtime_duration": attendance.overtime_duration, + "overtime_type": attendance.overtime_type, + "attendance_date": attendance.attendance_date, + } + + return records diff --git a/hrms/hr/doctype/overtime_type/__init__.py b/hrms/hr/doctype/overtime_type/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hrms/hr/doctype/overtime_type/overtime_type.js b/hrms/hr/doctype/overtime_type/overtime_type.js new file mode 100644 index 0000000000..06526d2707 --- /dev/null +++ b/hrms/hr/doctype/overtime_type/overtime_type.js @@ -0,0 +1,8 @@ +// Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Overtime Type", { +// refresh(frm) { + +// }, +// }); diff --git a/hrms/hr/doctype/overtime_type/overtime_type.json b/hrms/hr/doctype/overtime_type/overtime_type.json new file mode 100644 index 0000000000..5e64bb409e --- /dev/null +++ b/hrms/hr/doctype/overtime_type/overtime_type.json @@ -0,0 +1,127 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "Prompt", + "creation": "2024-10-12 23:46:31.408000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "section_break_pai1", + "overtime_salary_component", + "applicable_salary_component", + "column_break_lttw", + "maximum_overtime_hours_allowed", + "pay_rate_multipliers_section", + "standard_multiplier", + "applicable_for_public_holiday", + "public_holiday_multiplier", + "column_break_titx", + "applicable_for_weekend", + "weekend_multiplier" + ], + "fields": [ + { + "fieldname": "section_break_pai1", + "fieldtype": "Section Break", + "label": "Basic" + }, + { + "fieldname": "overtime_salary_component", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Overtime Salary Component", + "options": "Salary Component", + "reqd": 1 + }, + { + "fieldname": "applicable_salary_component", + "fieldtype": "Table MultiSelect", + "label": "Applicable Salary Component", + "options": "Overtime Salary Component", + "reqd": 1 + }, + { + "fieldname": "column_break_lttw", + "fieldtype": "Column Break" + }, + { + "fieldname": "maximum_overtime_hours_allowed", + "fieldtype": "Int", + "label": "Maximum Overtime Hours Allowed", + "non_negative": 1 + }, + { + "fieldname": "pay_rate_multipliers_section", + "fieldtype": "Section Break", + "label": "Pay Rate Multipliers" + }, + { + "fieldname": "standard_multiplier", + "fieldtype": "Float", + "label": "Standard Multiplier", + "non_negative": 1, + "reqd": 1 + }, + { + "default": "0", + "description": "If unchecked, the standard multiplier will be taken as default for the weekend.", + "fieldname": "applicable_for_weekend", + "fieldtype": "Check", + "label": "Applicable for Weekend", + "non_negative": 1, + "reqd": 1 + }, + { + "depends_on": "eval: doc.applicable_for_weekend == 1", + "fieldname": "weekend_multiplier", + "fieldtype": "Float", + "label": "Weekend Multiplier", + "mandatory_depends_on": "eval: doc.applicable_for_weekend == 1", + "non_negative": 1 + }, + { + "fieldname": "column_break_titx", + "fieldtype": "Column Break" + }, + { + "default": "0", + "description": "If unchecked, the standard multiplier will be taken as default for Public Holiday.", + "fieldname": "applicable_for_public_holiday", + "fieldtype": "Check", + "label": "Applicable for Public Holiday" + }, + { + "depends_on": "eval: doc.applicable_for_public_holiday == 1", + "fieldname": "public_holiday_multiplier", + "fieldtype": "Float", + "label": "Public Holiday Multiplier", + "mandatory_depends_on": "eval: doc.applicable_for_public_holiday == 1", + "non_negative": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2024-10-13 19:46:49.026344", + "modified_by": "Administrator", + "module": "HR", + "name": "Overtime Type", + "naming_rule": "Set by user", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/hrms/hr/doctype/overtime_type/overtime_type.py b/hrms/hr/doctype/overtime_type/overtime_type.py new file mode 100644 index 0000000000..c8c0790100 --- /dev/null +++ b/hrms/hr/doctype/overtime_type/overtime_type.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class OvertimeType(Document): + pass diff --git a/hrms/hr/doctype/overtime_type/test_overtime_type.py b/hrms/hr/doctype/overtime_type/test_overtime_type.py new file mode 100644 index 0000000000..9cb1355b87 --- /dev/null +++ b/hrms/hr/doctype/overtime_type/test_overtime_type.py @@ -0,0 +1,64 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +import frappe +from frappe.tests import IntegrationTestCase, UnitTestCase + +import erpnext + +from hrms.payroll.doctype.salary_slip.test_salary_slip import make_salary_component + +# On IntegrationTestCase, the doctype test records and all +# link-field test record depdendencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class TestOvertimeType(UnitTestCase): + """ + Unit tests for OvertimeType. + Use this class for testing individual functions and methods. + """ + + pass + + +def create_overtime_type(**args): + args = frappe._dict(args) + overtime_type = frappe.new_doc("Overtime Type") + overtime_type.name = "_Test Overtime" + + overtime_type.standard_multiplier = 1.25 + overtime_type.applicable_for_weekend = args.applicable_for_weekend or 0 + overtime_type.applicable_for_public_holiday = args.applicable_for_public_holiday or 0 + overtime_type.maximum_overtime_hours_allowed = args.maximum_overtime_hours_allowed or 0 + overtime_type.overtime_salary_component = args.overtime_salary_component or "Overtime Allowance" + + if args.applicable_for_weekend: + overtime_type.weekend_multiplier = 1.5 + + if args.applicable_for_public_holidays: + overtime_type.public_holiday_multiplier = 2 + + component = [ + { + "salary_component": "Basic Salary", + "abbr": "BA", + "type": "Earning", + }, + { + "salary_component": "Overtime Allowance", + "abbr": "OA", + "type": "Earning", + }, + ] + + company = erpnext.get_default_company() + make_salary_component(component, test_tax=0, company_list=[company]) + overtime_type.append("applicable_salary_component", {"salary_component": "Basic Salary"}) + + overtime_type.insert(ignore_if_duplicate=True) + + return overtime_type diff --git a/hrms/hr/doctype/shift_type/shift_type.json b/hrms/hr/doctype/shift_type/shift_type.json index fc7be8bb33..d65f17b8aa 100644 --- a/hrms/hr/doctype/shift_type/shift_type.json +++ b/hrms/hr/doctype/shift_type/shift_type.json @@ -29,7 +29,10 @@ "late_entry_grace_period", "column_break_18", "enable_early_exit_marking", - "early_exit_grace_period" + "early_exit_grace_period", + "overtime_section", + "allow_overtime", + "overtime_type" ], "fields": [ { @@ -176,6 +179,25 @@ "fieldtype": "Select", "label": "Roster Color", "options": "Blue\nCyan\nFuchsia\nGreen\nLime\nOrange\nPink\nRed\nViolet\nYellow" + }, + { + "fieldname": "overtime_section", + "fieldtype": "Section Break", + "label": "Overtime" + }, + { + "default": "0", + "fieldname": "allow_overtime", + "fieldtype": "Check", + "label": "Allow Overtime" + }, + { + "depends_on": "eval:doc.allow_overtime == 1", + "fieldname": "overtime_type", + "fieldtype": "Link", + "label": "Overtime Type", + "mandatory_depends_on": "eval:doc.allow_overtime == 1", + "options": "Overtime Type" } ], "links": [], diff --git a/hrms/payroll/doctype/salary_detail/salary_detail.json b/hrms/payroll/doctype/salary_detail/salary_detail.json index 7377ff5f8f..d6b47b911d 100644 --- a/hrms/payroll/doctype/salary_detail/salary_detail.json +++ b/hrms/payroll/doctype/salary_detail/salary_detail.json @@ -22,6 +22,7 @@ "variable_based_on_taxable_salary", "do_not_include_in_total", "deduct_full_tax_on_selected_payroll_date", + "overtime_slips", "section_break_2", "condition", "column_break_18", @@ -253,11 +254,17 @@ "fieldtype": "Check", "label": "Is Recurring Additional Salary", "read_only": 1 + }, + { + "fieldname": "overtime_slips", + "fieldtype": "Small Text", + "label": "Overtime Slip(s)", + "read_only": 1 } ], "istable": 1, "links": [], - "modified": "2024-03-27 13:10:34.183281", + "modified": "2024-10-20 14:26:42.552871", "modified_by": "Administrator", "module": "Payroll", "name": "Salary Detail", diff --git a/hrms/payroll/doctype/salary_slip/salary_slip.py b/hrms/payroll/doctype/salary_slip/salary_slip.py index a72b568b40..afb4dae6fa 100644 --- a/hrms/payroll/doctype/salary_slip/salary_slip.py +++ b/hrms/payroll/doctype/salary_slip/salary_slip.py @@ -3,7 +3,7 @@ import unicodedata -from datetime import date +from datetime import date, timedelta import frappe from frappe import _, msgprint @@ -27,6 +27,7 @@ rounded, ) from frappe.utils.background_jobs import enqueue +from frappe.utils.data import format_time import erpnext from erpnext.accounts.utils import get_fiscal_year @@ -206,6 +207,30 @@ def on_submit(self): self.email_salary_slip() self.update_payment_status_for_gratuity() + self.update_overtime_slip() + + def update_overtime_slip(self): + overtime_slips = [] + for data in self.earnings: + if data.overtime_slips: + overtime_slips.extend(data.overtime_slips.split(", ")) + + if self.docstatus == 1: + for slip in overtime_slips: + frappe.db.set_value("Overtime Slip", slip, "salary_slip", self.name) + + if self.docstatus == 2: + for slip in overtime_slips: + frappe.db.set_value("Overtime Slip", slip, "salary_slip", None) + frappe.db.set_value("Overtime Slip", slip, "docstatus", 1) + + frappe.msgprint( + msg=_("Unlinked Salary Slip from Overtime Slip"), + title=_("Unlinked Overtime Slips"), + indicator="blue", + is_minimizable=True, + wide=True, + ) def update_payment_status_for_gratuity(self): additional_salary = frappe.db.get_all( @@ -233,6 +258,9 @@ def on_cancel(self): cancel_loan_repayment_entry(self) self.publish_update() + def before_cancel(self): + self.update_overtime_slip() + def publish_update(self): employee_user = frappe.db.get_value("Employee", self.employee, "user_id", cache=True) frappe.publish_realtime( @@ -1092,6 +1120,9 @@ def calculate_component_amounts(self, component_type): self.add_structure_components(component_type) self.add_additional_salary_components(component_type) + + self.process_overtime_slips() + if component_type == "earnings": self.add_employee_benefits() else: @@ -1396,7 +1427,10 @@ def update_component_row( data=None, default_amount=None, remove_if_zero_valued=None, + processed_overtime_slips=None, ): + if processed_overtime_slips is None: + processed_overtime_slips = [] component_row = None for d in self.get(component_type): if d.salary_component != component_data.salary_component: @@ -1438,6 +1472,10 @@ def update_component_row( ): component_row.set(attr, component_data.get(attr)) + processed_overtime_slips = ", ".join(processed_overtime_slips) + if processed_overtime_slips: + component_row.overtime_slips = processed_overtime_slips + if additional_salary: if additional_salary.overwrite: component_row.additional_amount = flt( @@ -2084,6 +2122,199 @@ def add_leave_balances(self): }, ) + def process_overtime_slips(self): + overtime_slips = self.get_overtime_slips() + if overtime_slips: + amounts, processed_overtime_slips, overtime_salary_component = ( + self.get_overtime_type_details_and_amount(overtime_slips) + ) + self.add_overtime_component(amounts, processed_overtime_slips, overtime_salary_component) + + def get_overtime_slips(self): + return frappe.get_all( + "Overtime Slip", + filters={ + "employee": self.employee, + "posting_date": ("between", [self.start_date, self.end_date]), + "salary_slip": "", + "docstatus": 1, + "status": "Approved", + }, + fields=["name", "from_date", "to_date"], + ) + + def get_overtime_type_details_and_amount(self, overtime_slips): + processed_overtime_slips = [] + overtime_types_details = {} + overtime_salary_component = None + total_weekends_amount = total_public_holidays_amount = total_standard_amount = 0 + for slip in overtime_slips: + holiday_date_map = self.get_holiday_map(slip.from_date, slip.to_date) + overtime_details = self.get_overtime_details(slip.name) + + for overtime_detail in overtime_details: + overtime_types_details, overtime_salary_component = self.set_overtime_types_details( + overtime_types_details, overtime_detail + ) + + weekends_duration_amount, public_holidays_duration_amount, standard_duration_amount = ( + self.calculate_overtime_amount(overtime_detail, overtime_types_details, holiday_date_map) + ) + total_weekends_amount += weekends_duration_amount + total_public_holidays_amount += public_holidays_duration_amount + total_standard_amount += standard_duration_amount + + processed_overtime_slips.append(slip.name) + + return ( + [total_weekends_amount, total_public_holidays_amount, total_standard_amount], + processed_overtime_slips, + overtime_salary_component, + ) + + def calculate_overtime_amount(self, overtime_detail, overtime_types_details, holiday_date_map): + standard_duration_amount, weekends_duration_amount = 0, 0 + public_holidays_duration_amount, calculated_amount = 0, 0 + overtime_hours = convert_str_time_to_hours(overtime_detail.overtime_duration) + standard_working_hours = convert_str_time_to_hours(overtime_detail.standard_working_hours) + applicable_hourly_wages = self.get_applicable_hourly_wages( + overtime_types_details, overtime_detail.overtime_type, standard_working_hours + ) + + weekend_multiplier, public_holiday_multiplier = self.get_multipliers( + overtime_types_details, overtime_detail + ) + overtime_date = cstr(overtime_detail.date) + if overtime_date in holiday_date_map.keys(): + if holiday_date_map[overtime_date].weekly_off == 1: + calculated_amount = overtime_hours * applicable_hourly_wages * weekend_multiplier + weekends_duration_amount += calculated_amount + elif holiday_date_map[overtime_date].weekly_off == 0: + calculated_amount = overtime_hours * applicable_hourly_wages * public_holiday_multiplier + public_holidays_duration_amount += calculated_amount + else: + calculated_amount = ( + overtime_hours + * applicable_hourly_wages + * overtime_types_details[overtime_detail.overtime_type]["standard_multiplier"] + ) + standard_duration_amount += calculated_amount + + return weekends_duration_amount, public_holidays_duration_amount, standard_duration_amount + + def get_applicable_hourly_wages(self, overtime_types_details, overtime_type, standard_working_hours): + """ + Calculate the applicable hourly wage for overtime based on the standard working hours. + """ + return overtime_types_details[overtime_type]["applicable_daily_amount"] / standard_working_hours + + def get_multipliers(self, overtime_types_details, detail): + weekend_multiplier = overtime_types_details[detail.overtime_type]["standard_multiplier"] + public_holiday_multiplier = overtime_types_details[detail.overtime_type]["standard_multiplier"] + + if overtime_types_details[detail.overtime_type]["applicable_for_weekend"]: + weekend_multiplier = overtime_types_details[detail.overtime_type]["weekend_multiplier"] + if overtime_types_details[detail.overtime_type]["applicable_for_public_holiday"]: + public_holiday_multiplier = overtime_types_details[detail.overtime_type][ + "public_holiday_multiplier" + ] + + return weekend_multiplier, public_holiday_multiplier + + def get_holiday_map(self, from_date, to_date): + holiday_list = get_holiday_list_for_employee(self.employee) + holiday_dates = get_holiday_dates_between(holiday_list, from_date, to_date, as_dict=True) + + holiday_date_map = {} + for holiday_date in holiday_dates: + holiday_date_map[cstr(holiday_date.holiday_date)] = holiday_date + + return holiday_date_map + + def set_overtime_types_details(self, overtime_types_details, detail): + if detail.overtime_type not in overtime_types_details: + details, applicable_components = self.get_overtime_type_detail(detail.overtime_type) + overtime_types_details[detail.overtime_type] = details + + self.validate_applicable_components(applicable_components, detail.overtime_type) + + overtime_types_details[detail.overtime_type]["components"] = applicable_components + + if "applicable_amount" not in overtime_types_details[detail.overtime_type].keys(): + self.set_applicable_daily_amount(detail, overtime_types_details) + + return overtime_types_details, overtime_types_details[detail.overtime_type].overtime_salary_component + + def set_applicable_daily_amount(self, detail, overtime_types_details): + # Calculate and set the applicable daily amount in the dictionary + component_amount = self.calculate_component_amount(detail, overtime_types_details) + overtime_types_details[detail.overtime_type]["applicable_daily_amount"] = ( + component_amount / self.total_working_days + ) + + def calculate_component_amount(self, detail, overtime_types_details): + component_amount = sum( + [ + data.default_amount + for data in self.earnings + if data.salary_component in overtime_types_details[detail.overtime_type]["components"] + and not data.get("additional_salary", None) + ] + ) + return component_amount + + def validate_applicable_components(self, applicable_components, overtime_type): + if not len(applicable_components): + frappe.throw( + _("Select applicable components in Overtime Type: {0}").format(frappe.bold(overtime_type)) + ) + + def add_overtime_component(self, amounts, processed_overtime_slips, overtime_salary_component): + if not len(amounts): + return + if not overtime_salary_component: + frappe.throw( + _("Select {0} in {1}").format( + frappe.bold("Overtime Salary Component"), frappe.bold("Overtime Type") + ) + ) + + component_data = frappe._dict(get_salary_component_data(overtime_salary_component) or {}) + component_data.salary_component = overtime_salary_component + self.update_component_row( + component_data, sum(amounts), "earnings", processed_overtime_slips=processed_overtime_slips + ) + + def get_overtime_details(self, parent): + return frappe.get_all( + "Overtime Details", + filters={"parent": parent}, + fields=["date", "overtime_type", "overtime_duration", "standard_working_hours"], + ) + + def get_overtime_type_detail(self, name): + detail = frappe.get_value( + "Overtime Type", + filters={"name": name}, + fieldname=[ + "name", + "standard_multiplier", + "weekend_multiplier", + "public_holiday_multiplier", + "applicable_for_weekend", + "applicable_for_public_holiday", + "overtime_salary_component", + ], + as_dict=True, + ) + components = frappe.get_all( + "Overtime Salary Component", filters={"parent": name}, fields=["salary_component"] + ) + + components = [data.salary_component for data in components] + + return detail, components + def unlink_ref_doc_from_salary_slip(doc, method=None): """Unlinks accrual Journal Entry from Salary Slips on cancellation""" @@ -2342,3 +2573,18 @@ def email_salary_slips(names) -> None: for name in names: salary_slip = frappe.get_doc("Salary Slip", name) salary_slip.email_salary_slip() + + +def convert_str_time_to_hours(duration_str): + # Split the string into hours, minutes, and seconds + if isinstance(duration_str, timedelta): + duration_str = format_time(duration_str) + if not duration_str: + return + parts = duration_str.split(":") + hours = int(parts[0]) + minutes = int(parts[1]) if len(parts) > 1 else 0 + seconds = int(float(parts[2])) if len(parts) > 2 else 0 # Default to 0 if seconds are missing + + total_seconds = hours * 3600 + minutes * 60 + seconds + return total_seconds / 3600 diff --git a/hrms/payroll/doctype/salary_slip/test_salary_slip.py b/hrms/payroll/doctype/salary_slip/test_salary_slip.py index a3ab575da8..1e94412f99 100644 --- a/hrms/payroll/doctype/salary_slip/test_salary_slip.py +++ b/hrms/payroll/doctype/salary_slip/test_salary_slip.py @@ -1683,6 +1683,73 @@ def test_variable_tax_component(self): self.assertEqual(test_tds.accounts[0].company, salary_slip.company) self.assertListEqual(tax_component, ["_Test TDS"]) + def test_overtime_calculation(self): + from hrms.hr.doctype.overtime_slip.test_overtime_slip import ( + create_attendance_records_for_overtime, + create_overtime_slip, + ) + from hrms.hr.doctype.overtime_type.test_overtime_type import create_overtime_type + from hrms.hr.doctype.shift_type.test_shift_type import setup_shift_type + from hrms.payroll.doctype.salary_slip.salary_slip import convert_str_time_to_hours + from hrms.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure + + setup_shift_type(company="_Test Company") + + employee = make_employee("test_overtime_slipn@example.com") + salary_structure = make_salary_structure( + "Test Overtime Salary Slip", + "Monthly", + employee=employee, + company="_Test Company", + ) + + component = [ + { + "salary_component": "Overtime Allowance", + "abbr": "OA", + "type": "Earning", + "amount_based_on_formula": 0, + } + ] + + company = erpnext.get_default_company() + make_salary_component(component, test_tax=0, company_list=[company]) + + overtime_type = create_overtime_type(employee=employee) + attendance = create_attendance_records_for_overtime(employee, overtime_type=overtime_type.name) + + total_overtime_hours = 0 + for attendance_entry in attendance.values(): + total_overtime_hours += convert_str_time_to_hours(attendance_entry["overtime_duration"]) + + slip = create_overtime_slip(employee) + slip.status = "Approved" + slip.submit() + + salary_slip = make_salary_slip(salary_structure.name, employee=employee) + overtime_component_details = {} + applicable_amount = 0 + + for earning in salary_slip.earnings: + if earning.salary_component == "Overtime Allowance": + overtime_component_details = earning + + if earning.salary_component == "Basic Salary": + applicable_amount = earning.default_amount + + self.assertIn("Overtime Allowance", overtime_component_details.salary_component) + self.assertEqual(slip.name, overtime_component_details.overtime_slips) + + daily_wages = applicable_amount / salary_slip.total_working_days + + # Standard working hours is 4 + hourly_wages = daily_wages / 4 + + # formula = hourly wages * overtime hours * multiplier + overtime_amount = hourly_wages * total_overtime_hours * overtime_type.standard_multiplier + + self.assertEqual(flt(overtime_amount, 2), flt(overtime_component_details.amount, 2)) + class TestSalarySlipSafeEval(IntegrationTestCase): def test_safe_eval_for_salary_slip(self): @@ -2204,6 +2271,10 @@ def setup_test(): "Employee Benefit Claim", "Salary Structure Assignment", "Payroll Period", + "Overtime Type", + "Overtime Slip", + "Shift Type", + "Shift Assignment", ]: frappe.db.sql("delete from `tab%s`" % dt) diff --git a/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py b/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py index 00dc6a5eba..08f3496311 100644 --- a/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py +++ b/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py @@ -232,3 +232,14 @@ def get_tax_component(salary_structure: str) -> str | None: if cint(d.variable_based_on_taxable_salary) and not d.formula and not flt(d.amount): return d.salary_component return None + + +def get_assigned_salary_structure_assignment(employee, on_date): + if not employee or not on_date: + return None + return frappe.db.get_value( + "Salary Structure Assignment", + {"employee": employee, "docstatus": 1, "from_date": ("<=", on_date)}, + "*", + order_by="from_date desc", + ) diff --git a/hrms/utils/holiday_list.py b/hrms/utils/holiday_list.py index bfc39d9b17..22e86a8ec6 100644 --- a/hrms/utils/holiday_list.py +++ b/hrms/utils/holiday_list.py @@ -2,10 +2,7 @@ def get_holiday_dates_between( - holiday_list: str, - start_date: str, - end_date: str, - skip_weekly_offs: bool = False, + holiday_list: str, start_date: str, end_date: str, skip_weekly_offs: bool = False, as_dict: bool = False ) -> list: Holiday = frappe.qb.DocType("Holiday") query = ( @@ -18,6 +15,9 @@ def get_holiday_dates_between( if skip_weekly_offs: query = query.where(Holiday.weekly_off == 0) + if as_dict: + return query.run(as_dict=True) + return query.run(pluck=True)