From 3e80609f5c5f75844b7f6de9e4cecc7a661df529 Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Sun, 20 Oct 2024 15:10:50 +0530 Subject: [PATCH 01/12] feat: overtime feature --- hrms/hr/doctype/attendance/attendance.json | 35 +++- .../employee_checkin/employee_checkin.py | 61 ++++++ hrms/hr/doctype/overtime_details/__init__.py | 0 .../overtime_details/overtime_details.json | 77 +++++++ .../overtime_details/overtime_details.py | 9 + .../overtime_salary_component/__init__.py | 0 .../overtime_salary_component.json | 33 +++ .../overtime_salary_component.py | 9 + hrms/hr/doctype/overtime_slip/__init__.py | 0 .../hr/doctype/overtime_slip/overtime_slip.js | 42 ++++ .../doctype/overtime_slip/overtime_slip.json | 168 +++++++++++++++ .../hr/doctype/overtime_slip/overtime_slip.py | 121 +++++++++++ .../overtime_slip/test_overtime_slip.py | 30 +++ hrms/hr/doctype/overtime_type/__init__.py | 0 .../hr/doctype/overtime_type/overtime_type.js | 8 + .../doctype/overtime_type/overtime_type.json | 127 ++++++++++++ .../hr/doctype/overtime_type/overtime_type.py | 9 + .../overtime_type/test_overtime_type.py | 30 +++ hrms/hr/doctype/shift_type/shift_type.json | 26 ++- .../doctype/salary_detail/salary_detail.json | 9 +- .../doctype/salary_slip/salary_slip.py | 192 +++++++++++++++++- .../salary_structure_assignment.py | 11 + hrms/utils/holiday_list.py | 8 +- 23 files changed, 994 insertions(+), 11 deletions(-) create mode 100644 hrms/hr/doctype/overtime_details/__init__.py create mode 100644 hrms/hr/doctype/overtime_details/overtime_details.json create mode 100644 hrms/hr/doctype/overtime_details/overtime_details.py create mode 100644 hrms/hr/doctype/overtime_salary_component/__init__.py create mode 100644 hrms/hr/doctype/overtime_salary_component/overtime_salary_component.json create mode 100644 hrms/hr/doctype/overtime_salary_component/overtime_salary_component.py create mode 100644 hrms/hr/doctype/overtime_slip/__init__.py create mode 100644 hrms/hr/doctype/overtime_slip/overtime_slip.js create mode 100644 hrms/hr/doctype/overtime_slip/overtime_slip.json create mode 100644 hrms/hr/doctype/overtime_slip/overtime_slip.py create mode 100644 hrms/hr/doctype/overtime_slip/test_overtime_slip.py create mode 100644 hrms/hr/doctype/overtime_type/__init__.py create mode 100644 hrms/hr/doctype/overtime_type/overtime_type.js create mode 100644 hrms/hr/doctype/overtime_type/overtime_type.json create mode 100644 hrms/hr/doctype/overtime_type/overtime_type.py create mode 100644 hrms/hr/doctype/overtime_type/test_overtime_type.py diff --git a/hrms/hr/doctype/attendance/attendance.json b/hrms/hr/doctype/attendance/attendance.json index 1da67e0f61..a3d379ab6e 100644 --- a/hrms/hr/doctype/attendance/attendance.json +++ b/hrms/hr/doctype/attendance/attendance.json @@ -27,7 +27,12 @@ "column_break_18", "late_entry", "early_exit", - "amended_from" + "amended_from", + "overtime_section", + "overtime_duration", + "overtime_type", + "column_break_idku", + "standard_working_hours" ], "fields": [ { @@ -201,13 +206,39 @@ { "fieldname": "column_break_18", "fieldtype": "Column Break" + }, + { + "fieldname": "overtime_section", + "fieldtype": "Section Break", + "label": "Overtime" + }, + { + "fieldname": "overtime_duration", + "fieldtype": "Time", + "hide_days": 1, + "label": "Overtime Duration" + }, + { + "fieldname": "overtime_type", + "fieldtype": "Link", + "label": "Overtime Type", + "options": "Overtime Type" + }, + { + "fieldname": "column_break_idku", + "fieldtype": "Column Break" + }, + { + "fieldname": "standard_working_hours", + "fieldtype": "Time", + "label": "Standard Working Hours" } ], "icon": "fa fa-ok", "idx": 1, "is_submittable": 1, "links": [], - "modified": "2024-04-05 20:55:02.905452", + "modified": "2024-10-14 00:49:14.493086", "modified_by": "Administrator", "module": "HR", "name": "Attendance", diff --git a/hrms/hr/doctype/employee_checkin/employee_checkin.py b/hrms/hr/doctype/employee_checkin/employee_checkin.py index 9f2808f6d8..7785ed09e4 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 @@ -198,6 +201,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", @@ -336,3 +362,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/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..11eb9f71f6 --- /dev/null +++ b/hrms/hr/doctype/overtime_details/overtime_details.json @@ -0,0 +1,77 @@ +{ + "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 + }, + { + "fieldname": "standard_working_hours", + "fieldtype": "Data", + "label": "Standard Working Hours" + } + ], + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2024-10-19 11:24:38.307522", + "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..9dfa48e288 --- /dev/null +++ b/hrms/hr/doctype/overtime_slip/overtime_slip.js @@ -0,0 +1,42 @@ +// 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..05fd01e1fd --- /dev/null +++ b/hrms/hr/doctype/overtime_slip/overtime_slip.json @@ -0,0 +1,168 @@ +{ + "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 11:44:20.712273", + "modified_by": "Administrator", + "module": "HR", + "name": "Overtime Slip", + "naming_rule": "Expression (old style)", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "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..c5ae84a9af --- /dev/null +++ b/hrms/hr/doctype/overtime_slip/overtime_slip.py @@ -0,0 +1,121 @@ +# 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.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 +from frappe import _, bold + +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")) + + print(date_details, date_details.start_date, date_details.end_date) + 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() + print(records, "\n\n\n\n") + 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 = [] + print(self.from_date ,self.to_date, "HR-EMP-00001") + if self.from_date and self.to_date: + # records = frappe.db.sql("""SELECT overtime_duration, name, attendance_date, overtime_type, shift_duration + # FROM `tabAttendance` + # WHERE + # attendance_date >= %s AND attendance_date <= %s + # AND employee = %s + # AND docstatus = 1 AND status= 'Present' + # AND ( + # overtime_duration IS NOT NULL OR overtime_duration != '00:00:00.000000' + # ) + # """, (getdate(self.from_date), getdate(self.to_date), self.employee), as_dict=1) + # Add additional conditions for overtime_duration + + 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': ['is not', None], + 'overtime_type': ['!=', ''], + }, + debug=True + ) + print(records) + return records \ No newline at end of file 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..a7d6f5a2da --- /dev/null +++ b/hrms/hr/doctype/overtime_slip/test_overtime_slip.py @@ -0,0 +1,30 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase, UnitTestCase + + +# 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): + """ + Unit tests for OvertimeSlip. + Use this class for testing individual functions and methods. + """ + + pass + + +class TestOvertimeSlip(IntegrationTestCase): + """ + Integration tests for OvertimeSlip. + Use this class for testing interactions between multiple components. + """ + + pass 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..9aebdae33e --- /dev/null +++ b/hrms/hr/doctype/overtime_type/test_overtime_type.py @@ -0,0 +1,30 @@ +# Copyright (c) 2024, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase, UnitTestCase + + +# 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 + + +class TestOvertimeType(IntegrationTestCase): + """ + Integration tests for OvertimeType. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/hrms/hr/doctype/shift_type/shift_type.json b/hrms/hr/doctype/shift_type/shift_type.json index 29c6262cc7..361fa54b08 100644 --- a/hrms/hr/doctype/shift_type/shift_type.json +++ b/hrms/hr/doctype/shift_type/shift_type.json @@ -28,7 +28,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": [ { @@ -173,10 +176,29 @@ "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": [], - "modified": "2024-05-17 15:48:27.191003", + "modified": "2024-10-13 20:37:18.928843", "modified_by": "Administrator", "module": "HR", "name": "Shift Type", 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 bcfb69e6cf..b13f681d77 100644 --- a/hrms/payroll/doctype/salary_slip/salary_slip.py +++ b/hrms/payroll/doctype/salary_slip/salary_slip.py @@ -588,13 +588,13 @@ def get_payment_days(self, include_holidays_in_total_working_days): return payment_days - def get_holidays_for_employee(self, start_date, end_date): + def get_holidays_for_employee(self, start_date, end_date, as_dict=False): holiday_list = get_holiday_list_for_employee(self.employee) key = f"{holiday_list}:{start_date}:{end_date}" holiday_dates = frappe.cache().hget(HOLIDAYS_BETWEEN_DATES, key) if not holiday_dates: - holiday_dates = get_holiday_dates_between(holiday_list, start_date, end_date) + holiday_dates = get_holiday_dates_between(holiday_list, start_date, end_date, as_dict=as_dict) frappe.cache().hset(HOLIDAYS_BETWEEN_DATES, key, holiday_dates) return holiday_dates @@ -1081,6 +1081,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: @@ -1385,7 +1388,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: @@ -1427,6 +1433,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( @@ -2073,6 +2083,176 @@ def add_leave_balances(self): }, ) + def process_overtime_slips(self): + overtime_slips = self.get_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 + }, + fields=["name", "from_date", "to_date"], + ) + + def get_overtime_type_details_and_amount(self, overtime_slips): + standard_duration_amount, weekends_duration_amount = 0, 0 + public_holidays_duration_amount, calculated_amount = 0, 0 + processed_overtime_slips = [] + overtime_types_details = {} + for slip in overtime_slips: + holiday_date_map = self.get_holiday_map(slip.from_date, slip.to_date) + details = self.get_overtime_details(slip.name) + + for detail in details: + overtime_hours = convert_str_time_to_hours(detail.overtime_duration) + overtime_types_details, overtime_salary_component = self.set_overtime_types_details( + overtime_types_details, detail + ) + + standard_working_hours = convert_str_time_to_hours(detail.standard_working_hours) + + applicable_hourly_wages = ( + overtime_types_details[detail.overtime_type]["applicable_daily_amount"] + / standard_working_hours + ) + weekend_multiplier, public_holiday_multiplier = self.get_multipliers( + overtime_types_details, detail + ) + overtime_date = cstr(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[detail.overtime_type]["standard_multiplier"] + ) + standard_duration_amount += calculated_amount + + processed_overtime_slips.append(slip.name) + + return ( + [weekends_duration_amount, public_holidays_duration_amount, standard_duration_amount], + processed_overtime_slips, + overtime_salary_component, + ) + + 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_date = self.get_holidays_for_employee(from_date, to_date, as_dict=True) + + holiday_date_map = {} + try: + for date in holiday_date: + holiday_date_map[cstr(date.holiday_date)] = date + except Exception: + frappe.throw("Please try again after clearing your cache.") + + 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 + + if len(applicable_components): + overtime_types_details[detail.overtime_type]["components"] = applicable_components + else: + frappe.throw( + _("Select applicable components in Overtime Type: {0}").format( + frappe.bold(detail.overtime_type) + ) + ) + + if "applicable_amount" not in overtime_types_details[detail.overtime_type].keys(): + 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) + ] + ) + + overtime_types_details[detail.overtime_type]["applicable_daily_amount"] = ( + component_amount / self.total_working_days + ) + + return overtime_types_details, overtime_types_details[detail.overtime_type].overtime_salary_component + + 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""" @@ -2331,3 +2511,11 @@ 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 + hours, minutes, seconds = map(int, duration_str.split(":")) + total_seconds = hours * 3600 + minutes * 60 + seconds + + return total_seconds / 3600 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 48aad837e5..1c1bbd7294 100644 --- a/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py +++ b/hrms/payroll/doctype/salary_structure_assignment/salary_structure_assignment.py @@ -236,3 +236,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) From 526163f735915c2b8234f66fa1e5fe84cec9e0cf Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Sun, 20 Oct 2024 16:15:05 +0530 Subject: [PATCH 02/12] feat: update overtime slips on submit and cancel --- .../hr/doctype/overtime_slip/overtime_slip.js | 15 ++- .../doctype/overtime_slip/overtime_slip.json | 19 +++- .../hr/doctype/overtime_slip/overtime_slip.py | 103 +++++++++--------- .../overtime_slip/test_overtime_slip.py | 10 -- .../overtime_type/test_overtime_type.py | 10 -- .../doctype/salary_slip/salary_slip.py | 40 ++++++- 6 files changed, 109 insertions(+), 88 deletions(-) diff --git a/hrms/hr/doctype/overtime_slip/overtime_slip.js b/hrms/hr/doctype/overtime_slip/overtime_slip.js index 9dfa48e288..8c061d69f3 100644 --- a/hrms/hr/doctype/overtime_slip/overtime_slip.js +++ b/hrms/hr/doctype/overtime_slip/overtime_slip.js @@ -2,7 +2,7 @@ // For license information, please see license.txt frappe.ui.form.on("Overtime Slip", { - employee (frm) { + employee(frm) { if (frm.doc.employee) { frm.events.set_frequency_and_dates(frm).then(() => { frm.events.get_emp_details_and_overtime_duration(frm); @@ -10,32 +10,31 @@ frappe.ui.form.on("Overtime Slip", { } }, 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) { + set_frequency_and_dates: function (frm) { if (frm.doc.employee) { return frappe.call({ - method: 'get_frequency_and_dates', + method: "get_frequency_and_dates", doc: frm.doc, callback: function () { frm.refresh(); - } + }, }); } }, - get_emp_details_and_overtime_duration: function (frm) { + get_emp_details_and_overtime_duration: function (frm) { if (frm.doc.employee) { return frappe.call({ - method: 'get_emp_and_overtime_details', + 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 index 05fd01e1fd..9d8d7b0b73 100644 --- a/hrms/hr/doctype/overtime_slip/overtime_slip.json +++ b/hrms/hr/doctype/overtime_slip/overtime_slip.json @@ -141,7 +141,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2024-10-20 11:44:20.712273", + "modified": "2024-10-20 15:53:47.470320", "modified_by": "Administrator", "module": "HR", "name": "Overtime Slip", @@ -149,6 +149,7 @@ "owner": "Administrator", "permissions": [ { + "cancel": 1, "create": 1, "delete": 1, "email": 1, @@ -157,6 +158,22 @@ "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 diff --git a/hrms/hr/doctype/overtime_slip/overtime_slip.py b/hrms/hr/doctype/overtime_slip/overtime_slip.py index c5ae84a9af..ae14119c6d 100644 --- a/hrms/hr/doctype/overtime_slip/overtime_slip.py +++ b/hrms/hr/doctype/overtime_slip/overtime_slip.py @@ -3,16 +3,20 @@ 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 -from frappe import _, bold +from hrms.payroll.doctype.salary_structure_assignment.salary_structure_assignment import ( + get_assigned_salary_structure, +) -class OvertimeSlip(Document): +class OvertimeSlip(Document): def validate(self): if not (self.from_date or self.to_date or self.payroll_frequency): self.get_frequency_and_dates() @@ -25,18 +29,21 @@ def validate(self): 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) - }) + 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))) + bold(form_link), bold(formatdate(self.from_date)), bold(formatdate(self.to_date)) + ) frappe.throw(msg) def on_submit(self): @@ -45,15 +52,14 @@ def on_submit(self): @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")) - - print(date_details, date_details.start_date, date_details.end_date) + 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 @@ -63,59 +69,48 @@ def get_frequency_and_dates(self): @frappe.whitelist() def get_emp_and_overtime_details(self): records = self.get_attendance_record() - print(records, "\n\n\n\n") if len(records): self.create_overtime_details_row_for_attendance(records) if len(self.overtime_details): - self.total_overtime_duration = timedelta() + 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, - }) + 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 = [] - print(self.from_date ,self.to_date, "HR-EMP-00001") if self.from_date and self.to_date: - # records = frappe.db.sql("""SELECT overtime_duration, name, attendance_date, overtime_type, shift_duration - # FROM `tabAttendance` - # WHERE - # attendance_date >= %s AND attendance_date <= %s - # AND employee = %s - # AND docstatus = 1 AND status= 'Present' - # AND ( - # overtime_duration IS NOT NULL OR overtime_duration != '00:00:00.000000' - # ) - # """, (getdate(self.from_date), getdate(self.to_date), self.employee), as_dict=1) - # Add additional conditions for overtime_duration - - records = frappe.get_all('Attendance', - fields=['overtime_duration', 'name', 'attendance_date', 'overtime_type', 'standard_working_hours'], + 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': ['is not', None], - 'overtime_type': ['!=', ''], + "employee": self.employee, + "docstatus": DocStatus.submitted(), + "attendance_date": ("between", [getdate(self.from_date), getdate(self.to_date)]), + "status": "Present", + "overtime_type": ["!=", ""], }, - debug=True ) - print(records) - return records \ No newline at end of file + return records diff --git a/hrms/hr/doctype/overtime_slip/test_overtime_slip.py b/hrms/hr/doctype/overtime_slip/test_overtime_slip.py index a7d6f5a2da..738f24d373 100644 --- a/hrms/hr/doctype/overtime_slip/test_overtime_slip.py +++ b/hrms/hr/doctype/overtime_slip/test_overtime_slip.py @@ -4,7 +4,6 @@ # import frappe from frappe.tests import IntegrationTestCase, UnitTestCase - # 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 @@ -19,12 +18,3 @@ class TestOvertimeSlip(UnitTestCase): """ pass - - -class TestOvertimeSlip(IntegrationTestCase): - """ - Integration tests for OvertimeSlip. - Use this class for testing interactions between multiple components. - """ - - pass diff --git a/hrms/hr/doctype/overtime_type/test_overtime_type.py b/hrms/hr/doctype/overtime_type/test_overtime_type.py index 9aebdae33e..28d7512696 100644 --- a/hrms/hr/doctype/overtime_type/test_overtime_type.py +++ b/hrms/hr/doctype/overtime_type/test_overtime_type.py @@ -4,7 +4,6 @@ # import frappe from frappe.tests import IntegrationTestCase, UnitTestCase - # 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 @@ -19,12 +18,3 @@ class TestOvertimeType(UnitTestCase): """ pass - - -class TestOvertimeType(IntegrationTestCase): - """ - Integration tests for OvertimeType. - Use this class for testing interactions between multiple components. - """ - - pass diff --git a/hrms/payroll/doctype/salary_slip/salary_slip.py b/hrms/payroll/doctype/salary_slip/salary_slip.py index b13f681d77..296ade0988 100644 --- a/hrms/payroll/doctype/salary_slip/salary_slip.py +++ b/hrms/payroll/doctype/salary_slip/salary_slip.py @@ -206,6 +206,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 +257,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( @@ -2085,10 +2112,11 @@ def add_leave_balances(self): def process_overtime_slips(self): overtime_slips = self.get_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) + 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( @@ -2097,7 +2125,8 @@ def get_overtime_slips(self): "employee": self.employee, "posting_date": ("between", [self.start_date, self.end_date]), "salary_slip": "", - # 'docstatus': 1 + "docstatus": 1, + "status": "Approved", }, fields=["name", "from_date", "to_date"], ) @@ -2107,6 +2136,7 @@ def get_overtime_type_details_and_amount(self, overtime_slips): public_holidays_duration_amount, calculated_amount = 0, 0 processed_overtime_slips = [] overtime_types_details = {} + overtime_salary_component = None for slip in overtime_slips: holiday_date_map = self.get_holiday_map(slip.from_date, slip.to_date) details = self.get_overtime_details(slip.name) From 622fe247dec3f3c982c98d7901962098376f5f87 Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Sun, 20 Oct 2024 16:59:26 +0530 Subject: [PATCH 03/12] refactor: add translaction and use list comprehension --- hrms/payroll/doctype/salary_slip/salary_slip.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hrms/payroll/doctype/salary_slip/salary_slip.py b/hrms/payroll/doctype/salary_slip/salary_slip.py index 296ade0988..c7cba0fe87 100644 --- a/hrms/payroll/doctype/salary_slip/salary_slip.py +++ b/hrms/payroll/doctype/salary_slip/salary_slip.py @@ -2203,7 +2203,7 @@ def get_holiday_map(self, from_date, to_date): for date in holiday_date: holiday_date_map[cstr(date.holiday_date)] = date except Exception: - frappe.throw("Please try again after clearing your cache.") + frappe.throw(_("Please try again after clearing your cache.")) return holiday_date_map @@ -2545,7 +2545,7 @@ def email_salary_slips(names) -> None: def convert_str_time_to_hours(duration_str): # Split the string into hours, minutes, and seconds - hours, minutes, seconds = map(int, duration_str.split(":")) + hours, minutes, seconds = (int(part) for part in duration_str.split(":")) total_seconds = hours * 3600 + minutes * 60 + seconds return total_seconds / 3600 From 6a66206b886f97ce82572ecbb945770c64cbecbc Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Fri, 25 Oct 2024 23:17:42 +0530 Subject: [PATCH 04/12] feat: add validation for max days allowed --- hrms/hr/doctype/attendance/attendance.js | 19 +++++++++++ hrms/hr/doctype/attendance/attendance.json | 13 +++++-- hrms/hr/doctype/attendance/attendance.py | 34 +++++++++++++++++++ .../doctype/salary_slip/salary_slip.py | 5 ++- 4 files changed, 68 insertions(+), 3 deletions(-) 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 a3d379ab6e..3596fc5d56 100644 --- a/hrms/hr/doctype/attendance/attendance.json +++ b/hrms/hr/doctype/attendance/attendance.json @@ -32,7 +32,8 @@ "overtime_duration", "overtime_type", "column_break_idku", - "standard_working_hours" + "standard_working_hours", + "actual_overtime_duration" ], "fields": [ { @@ -213,12 +214,14 @@ "label": "Overtime" }, { + "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", @@ -232,13 +235,19 @@ "fieldname": "standard_working_hours", "fieldtype": "Time", "label": "Standard Working Hours" + }, + { + "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-10-14 00:49:14.493086", + "modified": "2024-10-25 11:55:43.603778", "modified_by": "Administrator", "module": "HR", "name": "Attendance", diff --git a/hrms/hr/doctype/attendance/attendance.py b/hrms/hr/doctype/attendance/attendance.py index 192c820d4f..fb916a6d63 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,22 @@ 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 maximum_overtime_hours: + 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") @@ -237,6 +250,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/payroll/doctype/salary_slip/salary_slip.py b/hrms/payroll/doctype/salary_slip/salary_slip.py index ff5c1dc63f..3e498f7083 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 @@ -2556,6 +2557,8 @@ def email_salary_slips(names) -> None: 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) hours, minutes, seconds = (int(part) for part in duration_str.split(":")) total_seconds = hours * 3600 + minutes * 60 + seconds From 612f687ad1ecb5af9e36065c1d7f2118695e6e2c Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Sun, 27 Oct 2024 16:51:11 +0530 Subject: [PATCH 05/12] test: write overtime slip test case --- hrms/hr/doctype/attendance/attendance.json | 5 +- .../employee_checkin/test_employee_checkin.py | 4 +- .../overtime_slip/test_overtime_slip.py | 98 ++++++++++++++++++- .../overtime_type/test_overtime_type.py | 25 +++++ 4 files changed, 124 insertions(+), 8 deletions(-) diff --git a/hrms/hr/doctype/attendance/attendance.json b/hrms/hr/doctype/attendance/attendance.json index 3596fc5d56..12bee14038 100644 --- a/hrms/hr/doctype/attendance/attendance.json +++ b/hrms/hr/doctype/attendance/attendance.json @@ -214,6 +214,7 @@ "label": "Overtime" }, { + "default": "0", "description": "Based on \"Maximum Overtime Hours Allowed\" in Overtime Type", "fieldname": "overtime_duration", "fieldtype": "Time", @@ -232,11 +233,13 @@ "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", @@ -247,7 +250,7 @@ "idx": 1, "is_submittable": 1, "links": [], - "modified": "2024-10-25 11:55:43.603778", + "modified": "2024-10-27 16:33:17.605504", "modified_by": "Administrator", "module": "HR", "name": "Attendance", 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_slip/test_overtime_slip.py b/hrms/hr/doctype/overtime_slip/test_overtime_slip.py index 738f24d373..7d448f5ef7 100644 --- a/hrms/hr/doctype/overtime_slip/test_overtime_slip.py +++ b/hrms/hr/doctype/overtime_slip/test_overtime_slip.py @@ -2,7 +2,20 @@ # See license.txt # import frappe +from datetime import timedelta + +import frappe from frappe.tests import IntegrationTestCase, UnitTestCase +from frappe.utils.data import 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_structure.test_salary_structure import ( + make_salary_structure, +) # On IntegrationTestCase, the doctype test records and all # link-field test record depdendencies are recursively loaded @@ -12,9 +25,84 @@ class TestOvertimeSlip(UnitTestCase): - """ - Unit tests for OvertimeSlip. - Use this class for testing individual functions and methods. - """ + 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 tearDown(self): + for doctype in [ + "Overtime Type", + "Overtime Slip", + "Attendance", + "Employee Checkin", + "Shift Type", + "Shift Assignment", + ]: + frappe.db.sql(f"DELETE FROM `tab{doctype}`") + frappe.db.commit() + + +def create_overtime_slip(employee): + slip = frappe.new_doc("Overtime Slip") + slip.employee = employee + slip.posting_date = today() + slip.overtime_details = [] - pass + slip.save() + return slip diff --git a/hrms/hr/doctype/overtime_type/test_overtime_type.py b/hrms/hr/doctype/overtime_type/test_overtime_type.py index 28d7512696..073f47807f 100644 --- a/hrms/hr/doctype/overtime_type/test_overtime_type.py +++ b/hrms/hr/doctype/overtime_type/test_overtime_type.py @@ -2,6 +2,7 @@ # See license.txt # import frappe +import frappe from frappe.tests import IntegrationTestCase, UnitTestCase # On IntegrationTestCase, the doctype test records and all @@ -18,3 +19,27 @@ class TestOvertimeType(UnitTestCase): """ 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" + + if args.applicable_for_weekend: + overtime_type.weekend_multiplier = 1.5 + + if args.applicable_for_public_holidays: + overtime_type.public_holiday_multiplier = 2 + + overtime_type.append("applicable_salary_component", {"salary_component": "Basic Salary"}) + + overtime_type.insert(ignore_if_duplicate=True) + + return overtime_type From 42a49b0f5b325971c8ae47bac7f555743f541bed Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Sun, 27 Oct 2024 22:13:52 +0530 Subject: [PATCH 06/12] test: write test case for salary slip Overtime --- .../overtime_details/overtime_details.json | 3 +- .../overtime_slip/test_overtime_slip.py | 50 ++++++++++++----- .../overtime_type/test_overtime_type.py | 16 +++++- .../doctype/salary_slip/salary_slip.py | 23 ++++---- .../doctype/salary_slip/test_salary_slip.py | 56 +++++++++++++++++++ 5 files changed, 123 insertions(+), 25 deletions(-) diff --git a/hrms/hr/doctype/overtime_details/overtime_details.json b/hrms/hr/doctype/overtime_details/overtime_details.json index 11eb9f71f6..bae6004883 100644 --- a/hrms/hr/doctype/overtime_details/overtime_details.json +++ b/hrms/hr/doctype/overtime_details/overtime_details.json @@ -57,6 +57,7 @@ "reqd": 1 }, { + "default": "0", "fieldname": "standard_working_hours", "fieldtype": "Data", "label": "Standard Working Hours" @@ -65,7 +66,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2024-10-19 11:24:38.307522", + "modified": "2024-10-27 19:40:06.521102", "modified_by": "Administrator", "module": "HR", "name": "Overtime Details", diff --git a/hrms/hr/doctype/overtime_slip/test_overtime_slip.py b/hrms/hr/doctype/overtime_slip/test_overtime_slip.py index 7d448f5ef7..2b5f1723b3 100644 --- a/hrms/hr/doctype/overtime_slip/test_overtime_slip.py +++ b/hrms/hr/doctype/overtime_slip/test_overtime_slip.py @@ -6,13 +6,14 @@ import frappe from frappe.tests import IntegrationTestCase, UnitTestCase -from frappe.utils.data import get_datetime, get_first_day, nowdate, today +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, ) @@ -25,6 +26,18 @@ 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") @@ -85,18 +98,6 @@ def test_create_overtime_slip(self): ) self.assertEqual(str(detail.date), str(records[detail.reference_document]["attendance_date"])) - def tearDown(self): - for doctype in [ - "Overtime Type", - "Overtime Slip", - "Attendance", - "Employee Checkin", - "Shift Type", - "Shift Assignment", - ]: - frappe.db.sql(f"DELETE FROM `tab{doctype}`") - frappe.db.commit() - def create_overtime_slip(employee): slip = frappe.new_doc("Overtime Slip") @@ -106,3 +107,26 @@ def create_overtime_slip(employee): 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/test_overtime_type.py b/hrms/hr/doctype/overtime_type/test_overtime_type.py index 073f47807f..2fe166e2dc 100644 --- a/hrms/hr/doctype/overtime_type/test_overtime_type.py +++ b/hrms/hr/doctype/overtime_type/test_overtime_type.py @@ -5,6 +5,10 @@ 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 @@ -30,7 +34,7 @@ def create_overtime_type(**args): 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" + overtime_type.overtime_salary_component = args.overtime_salary_component or "Overtime Allowance" if args.applicable_for_weekend: overtime_type.weekend_multiplier = 1.5 @@ -38,6 +42,16 @@ def create_overtime_type(**args): if args.applicable_for_public_holidays: overtime_type.public_holiday_multiplier = 2 + component = [ + { + "salary_component": "Basic Salary", + "abbr": "BA", + "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) diff --git a/hrms/payroll/doctype/salary_slip/salary_slip.py b/hrms/payroll/doctype/salary_slip/salary_slip.py index 3e498f7083..d1d2990205 100644 --- a/hrms/payroll/doctype/salary_slip/salary_slip.py +++ b/hrms/payroll/doctype/salary_slip/salary_slip.py @@ -616,13 +616,13 @@ def get_payment_days(self, include_holidays_in_total_working_days): return payment_days - def get_holidays_for_employee(self, start_date, end_date, as_dict=False): + def get_holidays_for_employee(self, start_date, end_date): holiday_list = get_holiday_list_for_employee(self.employee) key = f"{holiday_list}:{start_date}:{end_date}" holiday_dates = frappe.cache().hget(HOLIDAYS_BETWEEN_DATES, key) if not holiday_dates: - holiday_dates = get_holiday_dates_between(holiday_list, start_date, end_date, as_dict=as_dict) + holiday_dates = get_holiday_dates_between(holiday_list, start_date, end_date) frappe.cache().hset(HOLIDAYS_BETWEEN_DATES, key, holiday_dates) return holiday_dates @@ -2208,14 +2208,12 @@ def get_multipliers(self, overtime_types_details, detail): return weekend_multiplier, public_holiday_multiplier def get_holiday_map(self, from_date, to_date): - holiday_date = self.get_holidays_for_employee(from_date, to_date, as_dict=True) + 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 = {} - try: - for date in holiday_date: - holiday_date_map[cstr(date.holiday_date)] = date - except Exception: - frappe.throw(_("Please try again after clearing your cache.")) + for holiday_date in holiday_dates: + holiday_date_map[cstr(holiday_date.holiday_date)] = holiday_date return holiday_date_map @@ -2559,7 +2557,12 @@ 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) - hours, minutes, seconds = (int(part) for part in duration_str.split(":")) - total_seconds = hours * 3600 + minutes * 60 + seconds + 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..c285583300 100644 --- a/hrms/payroll/doctype/salary_slip/test_salary_slip.py +++ b/hrms/payroll/doctype/salary_slip/test_salary_slip.py @@ -1683,6 +1683,60 @@ 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.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure + + employee = make_employee("overtime_calc@salary.slip") + salary_structure = make_salary_structure("structure for Overtime 2", "Monthly", employee=employee) + + 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) + create_attendance_records_for_overtime(employee, overtime_type=overtime_type.name) + + 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 * 2 * 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 +2258,8 @@ def setup_test(): "Employee Benefit Claim", "Salary Structure Assignment", "Payroll Period", + "Overtime Type", + "Overtime Slip", ]: frappe.db.sql("delete from `tab%s`" % dt) From 73db2627b45d53b164ce33c108f6eb5d6c1e264d Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Sun, 27 Oct 2024 22:37:08 +0530 Subject: [PATCH 07/12] test: add overtime allowance salary component --- hrms/hr/doctype/overtime_type/test_overtime_type.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/hrms/hr/doctype/overtime_type/test_overtime_type.py b/hrms/hr/doctype/overtime_type/test_overtime_type.py index 2fe166e2dc..9cb1355b87 100644 --- a/hrms/hr/doctype/overtime_type/test_overtime_type.py +++ b/hrms/hr/doctype/overtime_type/test_overtime_type.py @@ -47,7 +47,12 @@ def create_overtime_type(**args): "salary_component": "Basic Salary", "abbr": "BA", "type": "Earning", - } + }, + { + "salary_component": "Overtime Allowance", + "abbr": "OA", + "type": "Earning", + }, ] company = erpnext.get_default_company() From 779e3255efa748aab39fb904be4370de2f81d061 Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Thu, 31 Oct 2024 10:48:16 +0530 Subject: [PATCH 08/12] refactor: return if maximum OT hours does not exists --- hrms/hr/doctype/attendance/attendance.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/hrms/hr/doctype/attendance/attendance.py b/hrms/hr/doctype/attendance/attendance.py index 853fa9a827..1125721d19 100644 --- a/hrms/hr/doctype/attendance/attendance.py +++ b/hrms/hr/doctype/attendance/attendance.py @@ -55,10 +55,11 @@ def validate_overtime_duration(self): "Overtime Type", self.overtime_type, "maximum_overtime_hours_allowed" ) self.actual_overtime_duration = self.overtime_duration - if maximum_overtime_hours: - 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" + 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") From fb8d26f5c98638b04f3a0e8ca95dcd42618b1e4b Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Thu, 31 Oct 2024 14:34:15 +0530 Subject: [PATCH 09/12] test: fix shift and overtime issue --- .../doctype/salary_slip/test_salary_slip.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/hrms/payroll/doctype/salary_slip/test_salary_slip.py b/hrms/payroll/doctype/salary_slip/test_salary_slip.py index c285583300..0270a3feda 100644 --- a/hrms/payroll/doctype/salary_slip/test_salary_slip.py +++ b/hrms/payroll/doctype/salary_slip/test_salary_slip.py @@ -1689,10 +1689,18 @@ def test_overtime_calculation(self): 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_structure.test_salary_structure import make_salary_structure - employee = make_employee("overtime_calc@salary.slip") - salary_structure = make_salary_structure("structure for Overtime 2", "Monthly", employee=employee) + 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 = [ { @@ -1733,7 +1741,7 @@ def test_overtime_calculation(self): hourly_wages = daily_wages / 4 # formula = hourly wages * overtime hours * multiplier - overtime_amount = hourly_wages * 2 * overtime_type.standard_multiplier + overtime_amount = hourly_wages * 4 * overtime_type.standard_multiplier self.assertEqual(flt(overtime_amount, 2), flt(overtime_component_details.amount, 2)) @@ -2260,6 +2268,8 @@ def setup_test(): "Payroll Period", "Overtime Type", "Overtime Slip", + "Shift Type", + "Shift Assignment", ]: frappe.db.sql("delete from `tab%s`" % dt) From 887a13d600c6c896bd44ec9f64363d48d763f3a2 Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Sat, 2 Nov 2024 12:08:00 +0530 Subject: [PATCH 10/12] refactor: calculate overtime hours dynamically --- hrms/payroll/doctype/salary_slip/test_salary_slip.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/hrms/payroll/doctype/salary_slip/test_salary_slip.py b/hrms/payroll/doctype/salary_slip/test_salary_slip.py index 0270a3feda..1e94412f99 100644 --- a/hrms/payroll/doctype/salary_slip/test_salary_slip.py +++ b/hrms/payroll/doctype/salary_slip/test_salary_slip.py @@ -1690,6 +1690,7 @@ def test_overtime_calculation(self): ) 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") @@ -1715,7 +1716,11 @@ def test_overtime_calculation(self): make_salary_component(component, test_tax=0, company_list=[company]) overtime_type = create_overtime_type(employee=employee) - create_attendance_records_for_overtime(employee, overtime_type=overtime_type.name) + 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" @@ -1741,7 +1746,7 @@ def test_overtime_calculation(self): hourly_wages = daily_wages / 4 # formula = hourly wages * overtime hours * multiplier - overtime_amount = hourly_wages * 4 * overtime_type.standard_multiplier + overtime_amount = hourly_wages * total_overtime_hours * overtime_type.standard_multiplier self.assertEqual(flt(overtime_amount, 2), flt(overtime_component_details.amount, 2)) From 173b1c398bacc7a122abb4de7d3e25f95456d01c Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Sat, 23 Nov 2024 00:19:56 +0530 Subject: [PATCH 11/12] refactor: break big fucntion into small function --- .../doctype/salary_slip/salary_slip.py | 78 +++++++++++-------- 1 file changed, 46 insertions(+), 32 deletions(-) diff --git a/hrms/payroll/doctype/salary_slip/salary_slip.py b/hrms/payroll/doctype/salary_slip/salary_slip.py index d1d2990205..150b8a1bcc 100644 --- a/hrms/payroll/doctype/salary_slip/salary_slip.py +++ b/hrms/payroll/doctype/salary_slip/salary_slip.py @@ -2144,56 +2144,70 @@ def get_overtime_slips(self): ) def get_overtime_type_details_and_amount(self, overtime_slips): - standard_duration_amount, weekends_duration_amount = 0, 0 - public_holidays_duration_amount, calculated_amount = 0, 0 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) - details = self.get_overtime_details(slip.name) + overtime_details = self.get_overtime_details(slip.name) - for detail in details: - overtime_hours = convert_str_time_to_hours(detail.overtime_duration) + for overtime_detail in overtime_details: overtime_types_details, overtime_salary_component = self.set_overtime_types_details( - overtime_types_details, detail + overtime_types_details, overtime_detail ) - standard_working_hours = convert_str_time_to_hours(detail.standard_working_hours) - - applicable_hourly_wages = ( - overtime_types_details[detail.overtime_type]["applicable_daily_amount"] - / standard_working_hours + weekends_duration_amount, public_holidays_duration_amount, standard_duration_amount = ( + self.calculate_overtime_amount(overtime_detail, overtime_types_details, holiday_date_map) ) - weekend_multiplier, public_holiday_multiplier = self.get_multipliers( - overtime_types_details, detail - ) - overtime_date = cstr(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[detail.overtime_type]["standard_multiplier"] - ) - standard_duration_amount += calculated_amount + 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 ( - [weekends_duration_amount, public_holidays_duration_amount, standard_duration_amount], + [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"] From b800049fba1e861ed17075c890d3afb07c14f62c Mon Sep 17 00:00:00 2001 From: Ejaaz Khan Date: Sat, 23 Nov 2024 00:46:40 +0530 Subject: [PATCH 12/12] refactor: break down set_overtime_types_details into smaller functions --- .../doctype/salary_slip/salary_slip.py | 48 +++++++++++-------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/hrms/payroll/doctype/salary_slip/salary_slip.py b/hrms/payroll/doctype/salary_slip/salary_slip.py index 150b8a1bcc..afb4dae6fa 100644 --- a/hrms/payroll/doctype/salary_slip/salary_slip.py +++ b/hrms/payroll/doctype/salary_slip/salary_slip.py @@ -2236,31 +2236,39 @@ def set_overtime_types_details(self, overtime_types_details, detail): details, applicable_components = self.get_overtime_type_detail(detail.overtime_type) overtime_types_details[detail.overtime_type] = details - if len(applicable_components): - overtime_types_details[detail.overtime_type]["components"] = applicable_components - else: - frappe.throw( - _("Select applicable components in Overtime Type: {0}").format( - frappe.bold(detail.overtime_type) - ) - ) + self.validate_applicable_components(applicable_components, detail.overtime_type) - if "applicable_amount" not in overtime_types_details[detail.overtime_type].keys(): - 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) - ] - ) + overtime_types_details[detail.overtime_type]["components"] = applicable_components - overtime_types_details[detail.overtime_type]["applicable_daily_amount"] = ( - component_amount / self.total_working_days - ) + 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