diff --git a/erpnext/hooks.py b/erpnext/hooks.py index c5d6a8af030..1176b8c0717 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -436,6 +436,11 @@ scheduler_events = { "erpnext.crm.doctype.opportunity.opportunity.auto_close_opportunity", "erpnext.controllers.accounts_controller.update_invoice_status", "erpnext.accounts.doctype.fiscal_year.fiscal_year.auto_create_fiscal_year", +<<<<<<< HEAD +======= + "erpnext.hr.doctype.employee.employee_reminders.send_work_anniversary_reminders", + "erpnext.hr.doctype.employee.employee_reminders.send_birthday_reminders", +>>>>>>> 24b2a31581 (feat: Employee reminders (#25735)) "erpnext.projects.doctype.task.task.set_tasks_as_overdue", "erpnext.stock.doctype.serial_no.serial_no.update_maintenance_status", "erpnext.buying.doctype.supplier_scorecard.supplier_scorecard.refresh_scorecards", @@ -466,6 +471,12 @@ scheduler_events = { "erpnext.crm.utils.open_leads_opportunities_based_on_todays_event", "erpnext.assets.doctype.asset.depreciation.post_depreciation_entries", ], + "weekly": [ + "erpnext.hr.doctype.employee.employee_reminders.send_reminders_in_advance_weekly" + ], + "monthly": [ + "erpnext.hr.doctype.employee.employee_reminders.send_reminders_in_advance_monthly" + ], "monthly_long": [ "erpnext.accounts.deferred_revenue.process_deferred_accounting", "erpnext.accounts.utils.auto_create_exchange_rate_revaluation_monthly", diff --git a/erpnext/hr/doctype/compensatory_leave_request/compensatory_leave_request.py b/erpnext/hr/doctype/compensatory_leave_request/compensatory_leave_request.py new file mode 100644 index 00000000000..3db81654a65 --- /dev/null +++ b/erpnext/hr/doctype/compensatory_leave_request/compensatory_leave_request.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _ +from frappe.utils import date_diff, add_days, getdate, cint, format_date +from frappe.model.document import Document +from erpnext.hr.utils import validate_dates, validate_overlap, get_leave_period, validate_active_employee, \ + create_additional_leave_ledger_entry, get_holiday_dates_for_employee + +class CompensatoryLeaveRequest(Document): + + def validate(self): + validate_active_employee(self.employee) + validate_dates(self, self.work_from_date, self.work_end_date) + if self.half_day: + if not self.half_day_date: + frappe.throw(_("Half Day Date is mandatory")) + if not getdate(self.work_from_date)<=getdate(self.half_day_date)<=getdate(self.work_end_date): + frappe.throw(_("Half Day Date should be in between Work From Date and Work End Date")) + validate_overlap(self, self.work_from_date, self.work_end_date) + self.validate_holidays() + self.validate_attendance() + if not self.leave_type: + frappe.throw(_("Leave Type is madatory")) + + def validate_attendance(self): + attendance = frappe.get_all('Attendance', + filters={ + 'attendance_date': ['between', (self.work_from_date, self.work_end_date)], + 'status': 'Present', + 'docstatus': 1, + 'employee': self.employee + }, fields=['attendance_date', 'status']) + + if len(attendance) < date_diff(self.work_end_date, self.work_from_date) + 1: + frappe.throw(_("You are not present all day(s) between compensatory leave request days")) + + def validate_holidays(self): + holidays = get_holiday_dates_for_employee(self.employee, self.work_from_date, self.work_end_date) + if len(holidays) < date_diff(self.work_end_date, self.work_from_date) + 1: + if date_diff(self.work_end_date, self.work_from_date): + msg = _("The days between {0} to {1} are not valid holidays.").format(frappe.bold(format_date(self.work_from_date)), frappe.bold(format_date(self.work_end_date))) + else: + msg = _("{0} is not a holiday.").format(frappe.bold(format_date(self.work_from_date))) + + frappe.throw(msg) + + def on_submit(self): + company = frappe.db.get_value("Employee", self.employee, "company") + date_difference = date_diff(self.work_end_date, self.work_from_date) + 1 + if self.half_day: + date_difference -= 0.5 + leave_period = get_leave_period(self.work_from_date, self.work_end_date, company) + if leave_period: + leave_allocation = self.get_existing_allocation_for_period(leave_period) + if leave_allocation: + leave_allocation.new_leaves_allocated += date_difference + leave_allocation.validate() + leave_allocation.db_set("new_leaves_allocated", leave_allocation.total_leaves_allocated) + leave_allocation.db_set("total_leaves_allocated", leave_allocation.total_leaves_allocated) + + # generate additional ledger entry for the new compensatory leaves off + create_additional_leave_ledger_entry(leave_allocation, date_difference, add_days(self.work_end_date, 1)) + + else: + leave_allocation = self.create_leave_allocation(leave_period, date_difference) + self.db_set("leave_allocation", leave_allocation.name) + else: + frappe.throw(_("There is no leave period in between {0} and {1}").format(format_date(self.work_from_date), format_date(self.work_end_date))) + + def on_cancel(self): + if self.leave_allocation: + date_difference = date_diff(self.work_end_date, self.work_from_date) + 1 + if self.half_day: + date_difference -= 0.5 + leave_allocation = frappe.get_doc("Leave Allocation", self.leave_allocation) + if leave_allocation: + leave_allocation.new_leaves_allocated -= date_difference + if leave_allocation.new_leaves_allocated - date_difference <= 0: + leave_allocation.new_leaves_allocated = 0 + leave_allocation.validate() + leave_allocation.db_set("new_leaves_allocated", leave_allocation.total_leaves_allocated) + leave_allocation.db_set("total_leaves_allocated", leave_allocation.total_leaves_allocated) + + # create reverse entry on cancelation + create_additional_leave_ledger_entry(leave_allocation, date_difference * -1, add_days(self.work_end_date, 1)) + + def get_existing_allocation_for_period(self, leave_period): + leave_allocation = frappe.db.sql(""" + select name + from `tabLeave Allocation` + where employee=%(employee)s and leave_type=%(leave_type)s + and docstatus=1 + and (from_date between %(from_date)s and %(to_date)s + or to_date between %(from_date)s and %(to_date)s + or (from_date < %(from_date)s and to_date > %(to_date)s)) + """, { + "from_date": leave_period[0].from_date, + "to_date": leave_period[0].to_date, + "employee": self.employee, + "leave_type": self.leave_type + }, as_dict=1) + + if leave_allocation: + return frappe.get_doc("Leave Allocation", leave_allocation[0].name) + else: + return False + + def create_leave_allocation(self, leave_period, date_difference): + is_carry_forward = frappe.db.get_value("Leave Type", self.leave_type, "is_carry_forward") + allocation = frappe.get_doc(dict( + doctype="Leave Allocation", + employee=self.employee, + employee_name=self.employee_name, + leave_type=self.leave_type, + from_date=add_days(self.work_end_date, 1), + to_date=leave_period[0].to_date, + carry_forward=cint(is_carry_forward), + new_leaves_allocated=date_difference, + total_leaves_allocated=date_difference, + description=self.reason + )) + allocation.insert(ignore_permissions=True) + allocation.submit() + return allocation diff --git a/erpnext/hr/doctype/employee/employee.py b/erpnext/hr/doctype/employee/employee.py new file mode 100755 index 00000000000..643f3da2ff7 --- /dev/null +++ b/erpnext/hr/doctype/employee/employee.py @@ -0,0 +1,456 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt +import frappe + +from frappe.utils import getdate, validate_email_address, today, add_years, cstr +from frappe.model.naming import set_name_by_naming_series +from frappe import throw, _, scrub +from frappe.permissions import add_user_permission, remove_user_permission, \ + set_user_permission_if_allowed, has_permission, get_doc_permissions +from erpnext.utilities.transaction_base import delete_events +from frappe.utils.nestedset import NestedSet + +class EmployeeUserDisabledError(frappe.ValidationError): + pass +class InactiveEmployeeStatusError(frappe.ValidationError): + pass + +class Employee(NestedSet): + nsm_parent_field = 'reports_to' + + def autoname(self): + naming_method = frappe.db.get_value("HR Settings", None, "emp_created_by") + if not naming_method: + throw(_("Please setup Employee Naming System in Human Resource > HR Settings")) + else: + if naming_method == 'Naming Series': + set_name_by_naming_series(self) + elif naming_method == 'Employee Number': + self.name = self.employee_number + elif naming_method == 'Full Name': + self.set_employee_name() + self.name = self.employee_name + + self.employee = self.name + + def validate(self): + from erpnext.controllers.status_updater import validate_status + validate_status(self.status, ["Active", "Inactive", "Suspended", "Left"]) + + self.employee = self.name + self.set_employee_name() + self.validate_date() + self.validate_email() + self.validate_status() + self.validate_reports_to() + self.validate_preferred_email() + if self.job_applicant: + self.validate_onboarding_process() + + if self.user_id: + self.validate_user_details() + else: + existing_user_id = frappe.db.get_value("Employee", self.name, "user_id") + if existing_user_id: + remove_user_permission( + "Employee", self.name, existing_user_id) + + def after_rename(self, old, new, merge): + self.db_set("employee", new) + + def set_employee_name(self): + self.employee_name = ' '.join(filter(lambda x: x, [self.first_name, self.middle_name, self.last_name])) + + def validate_user_details(self): + data = frappe.db.get_value('User', + self.user_id, ['enabled', 'user_image'], as_dict=1) + if data.get("user_image") and self.image == '': + self.image = data.get("user_image") + self.validate_for_enabled_user_id(data.get("enabled", 0)) + self.validate_duplicate_user_id() + + def update_nsm_model(self): + frappe.utils.nestedset.update_nsm(self) + + def on_update(self): + self.update_nsm_model() + if self.user_id: + self.update_user() + self.update_user_permissions() + self.reset_employee_emails_cache() + self.update_approver_role() + + def update_user_permissions(self): + if not self.create_user_permission: return + if not has_permission('User Permission', ptype='write', raise_exception=False): return + + employee_user_permission_exists = frappe.db.exists('User Permission', { + 'allow': 'Employee', + 'for_value': self.name, + 'user': self.user_id + }) + + if employee_user_permission_exists: return + + employee_user_permission_exists = frappe.db.exists('User Permission', { + 'allow': 'Employee', + 'for_value': self.name, + 'user': self.user_id + }) + + if employee_user_permission_exists: return + + add_user_permission("Employee", self.name, self.user_id) + set_user_permission_if_allowed("Company", self.company, self.user_id) + + def update_user(self): + # add employee role if missing + user = frappe.get_doc("User", self.user_id) + user.flags.ignore_permissions = True + + if "Employee" not in user.get("roles"): + user.append_roles("Employee") + + # copy details like Fullname, DOB and Image to User + if self.employee_name and not (user.first_name and user.last_name): + employee_name = self.employee_name.split(" ") + if len(employee_name) >= 3: + user.last_name = " ".join(employee_name[2:]) + user.middle_name = employee_name[1] + elif len(employee_name) == 2: + user.last_name = employee_name[1] + + user.first_name = employee_name[0] + + if self.date_of_birth: + user.birth_date = self.date_of_birth + + if self.gender: + user.gender = self.gender + + if self.image: + if not user.user_image: + user.user_image = self.image + try: + frappe.get_doc({ + "doctype": "File", + "file_url": self.image, + "attached_to_doctype": "User", + "attached_to_name": self.user_id + }).insert() + except frappe.DuplicateEntryError: + # already exists + pass + + user.save() + + def update_approver_role(self): + if self.leave_approver: + user = frappe.get_doc("User", self.leave_approver) + user.flags.ignore_permissions = True + user.add_roles("Leave Approver") + + if self.expense_approver: + user = frappe.get_doc("User", self.expense_approver) + user.flags.ignore_permissions = True + user.add_roles("Expense Approver") + + def validate_date(self): + if self.date_of_birth and getdate(self.date_of_birth) > getdate(today()): + throw(_("Date of Birth cannot be greater than today.")) + + if self.date_of_birth and self.date_of_joining and getdate(self.date_of_birth) >= getdate(self.date_of_joining): + throw(_("Date of Joining must be greater than Date of Birth")) + + elif self.date_of_retirement and self.date_of_joining and (getdate(self.date_of_retirement) <= getdate(self.date_of_joining)): + throw(_("Date Of Retirement must be greater than Date of Joining")) + + elif self.relieving_date and self.date_of_joining and (getdate(self.relieving_date) < getdate(self.date_of_joining)): + throw(_("Relieving Date must be greater than or equal to Date of Joining")) + + elif self.contract_end_date and self.date_of_joining and (getdate(self.contract_end_date) <= getdate(self.date_of_joining)): + throw(_("Contract End Date must be greater than Date of Joining")) + + def validate_email(self): + if self.company_email: + validate_email_address(self.company_email, True) + if self.personal_email: + validate_email_address(self.personal_email, True) + + def set_preferred_email(self): + preferred_email_field = frappe.scrub(self.prefered_contact_email) + if preferred_email_field: + preferred_email = self.get(preferred_email_field) + self.prefered_email = preferred_email + + def validate_status(self): + if self.status == 'Left': + reports_to = frappe.db.get_all('Employee', + filters={'reports_to': self.name, 'status': "Active"}, + fields=['name','employee_name'] + ) + if reports_to: + link_to_employees = [frappe.utils.get_link_to_form('Employee', employee.name, label=employee.employee_name) for employee in reports_to] + message = _("The following employees are currently still reporting to {0}:").format(frappe.bold(self.employee_name)) + message += "


" + message += _("Please make sure the employees above report to another Active employee.") + throw(message, InactiveEmployeeStatusError, _("Cannot Relieve Employee")) + if not self.relieving_date: + throw(_("Please enter relieving date.")) + + def validate_for_enabled_user_id(self, enabled): + if not self.status == 'Active': + return + + if enabled is None: + frappe.throw(_("User {0} does not exist").format(self.user_id)) + if enabled == 0: + frappe.throw(_("User {0} is disabled").format(self.user_id), EmployeeUserDisabledError) + + def validate_duplicate_user_id(self): + employee = frappe.db.sql_list("""select name from `tabEmployee` where + user_id=%s and status='Active' and name!=%s""", (self.user_id, self.name)) + if employee: + throw(_("User {0} is already assigned to Employee {1}").format( + self.user_id, employee[0]), frappe.DuplicateEntryError) + + def validate_reports_to(self): + if self.reports_to == self.name: + throw(_("Employee cannot report to himself.")) + + def on_trash(self): + self.update_nsm_model() + delete_events(self.doctype, self.name) + if frappe.db.exists("Employee Transfer", {'new_employee_id': self.name, 'docstatus': 1}): + emp_transfer = frappe.get_doc("Employee Transfer", {'new_employee_id': self.name, 'docstatus': 1}) + emp_transfer.db_set("new_employee_id", '') + + def validate_preferred_email(self): + if self.prefered_contact_email and not self.get(scrub(self.prefered_contact_email)): + frappe.msgprint(_("Please enter {0}").format(self.prefered_contact_email)) + + def validate_onboarding_process(self): + employee_onboarding = frappe.get_all("Employee Onboarding", + filters={"job_applicant": self.job_applicant, "docstatus": 1, "boarding_status": ("!=", "Completed")}) + if employee_onboarding: + doc = frappe.get_doc("Employee Onboarding", employee_onboarding[0].name) + doc.validate_employee_creation() + doc.db_set("employee", self.name) + + def reset_employee_emails_cache(self): + prev_doc = self.get_doc_before_save() or {} + cell_number = cstr(self.get('cell_number')) + prev_number = cstr(prev_doc.get('cell_number')) + if (cell_number != prev_number or + self.get('user_id') != prev_doc.get('user_id')): + frappe.cache().hdel('employees_with_number', cell_number) + frappe.cache().hdel('employees_with_number', prev_number) + +def get_timeline_data(doctype, name): + '''Return timeline for attendance''' + return dict(frappe.db.sql('''select unix_timestamp(attendance_date), count(*) + from `tabAttendance` where employee=%s + and attendance_date > date_sub(curdate(), interval 1 year) + and status in ('Present', 'Half Day') + group by attendance_date''', name)) + +@frappe.whitelist() +def get_retirement_date(date_of_birth=None): + ret = {} + if date_of_birth: + try: + retirement_age = int(frappe.db.get_single_value("HR Settings", "retirement_age") or 60) + dt = add_years(getdate(date_of_birth),retirement_age) + ret = {'date_of_retirement': dt.strftime('%Y-%m-%d')} + except ValueError: + # invalid date + ret = {} + + return ret + +def validate_employee_role(doc, method): + # called via User hook + if "Employee" in [d.role for d in doc.get("roles")]: + if not frappe.db.get_value("Employee", {"user_id": doc.name}): + frappe.msgprint(_("Please set User ID field in an Employee record to set Employee Role")) + doc.get("roles").remove(doc.get("roles", {"role": "Employee"})[0]) + +def update_user_permissions(doc, method): + # called via User hook + if "Employee" in [d.role for d in doc.get("roles")]: + if not has_permission('User Permission', ptype='write', raise_exception=False): return + employee = frappe.get_doc("Employee", {"user_id": doc.name}) + employee.update_user_permissions() + +def get_employee_email(employee_doc): + return employee_doc.get("user_id") or employee_doc.get("personal_email") or employee_doc.get("company_email") + +def get_holiday_list_for_employee(employee, raise_exception=True): + if employee: + holiday_list, company = frappe.db.get_value("Employee", employee, ["holiday_list", "company"]) + else: + holiday_list='' + company=frappe.db.get_value("Global Defaults", None, "default_company") + + if not holiday_list: + holiday_list = frappe.get_cached_value('Company', company, "default_holiday_list") + + if not holiday_list and raise_exception: + frappe.throw(_('Please set a default Holiday List for Employee {0} or Company {1}').format(employee, company)) + + return holiday_list + +def is_holiday(employee, date=None, raise_exception=True, only_non_weekly=False, with_description=False): + ''' + Returns True if given Employee has an holiday on the given date + :param employee: Employee `name` + :param date: Date to check. Will check for today if None + :param raise_exception: Raise an exception if no holiday list found, default is True + :param only_non_weekly: Check only non-weekly holidays, default is False + ''' + + holiday_list = get_holiday_list_for_employee(employee, raise_exception) + if not date: + date = today() + + if not holiday_list: + return False + + filters = { + 'parent': holiday_list, + 'holiday_date': date + } + if only_non_weekly: + filters['weekly_off'] = False + + holidays = frappe.get_all( + 'Holiday', + fields=['description'], + filters=filters, + pluck='description' + ) + + if with_description: + return len(holidays) > 0, holidays + + return len(holidays) > 0 + +@frappe.whitelist() +def deactivate_sales_person(status = None, employee = None): + if status == "Left": + sales_person = frappe.db.get_value("Sales Person", {"Employee": employee}) + if sales_person: + frappe.db.set_value("Sales Person", sales_person, "enabled", 0) + +@frappe.whitelist() +def create_user(employee, user = None, email=None): + emp = frappe.get_doc("Employee", employee) + + employee_name = emp.employee_name.split(" ") + middle_name = last_name = "" + + if len(employee_name) >= 3: + last_name = " ".join(employee_name[2:]) + middle_name = employee_name[1] + elif len(employee_name) == 2: + last_name = employee_name[1] + + first_name = employee_name[0] + + if email: + emp.prefered_email = email + + user = frappe.new_doc("User") + user.update({ + "name": emp.employee_name, + "email": emp.prefered_email, + "enabled": 1, + "first_name": first_name, + "middle_name": middle_name, + "last_name": last_name, + "gender": emp.gender, + "birth_date": emp.date_of_birth, + "phone": emp.cell_number, + "bio": emp.bio + }) + user.insert() + return user.name + +def get_all_employee_emails(company): + '''Returns list of employee emails either based on user_id or company_email''' + employee_list = frappe.get_all('Employee', + fields=['name','employee_name'], + filters={ + 'status': 'Active', + 'company': company + } + ) + employee_emails = [] + for employee in employee_list: + if not employee: + continue + user, company_email, personal_email = frappe.db.get_value('Employee', + employee, ['user_id', 'company_email', 'personal_email']) + email = user or company_email or personal_email + if email: + employee_emails.append(email) + return employee_emails + +def get_employee_emails(employee_list): + '''Returns list of employee emails either based on user_id or company_email''' + employee_emails = [] + for employee in employee_list: + if not employee: + continue + user, company_email, personal_email = frappe.db.get_value('Employee', employee, + ['user_id', 'company_email', 'personal_email']) + email = user or company_email or personal_email + if email: + employee_emails.append(email) + return employee_emails + +@frappe.whitelist() +def get_children(doctype, parent=None, company=None, is_root=False, is_tree=False): + + filters = [['status', '=', 'Active']] + if company and company != 'All Companies': + filters.append(['company', '=', company]) + + fields = ['name as value', 'employee_name as title'] + + if is_root: + parent = '' + if parent and company and parent!=company: + filters.append(['reports_to', '=', parent]) + else: + filters.append(['reports_to', '=', '']) + + employees = frappe.get_list(doctype, fields=fields, + filters=filters, order_by='name') + + for employee in employees: + is_expandable = frappe.get_all(doctype, filters=[ + ['reports_to', '=', employee.get('value')] + ]) + employee.expandable = 1 if is_expandable else 0 + + return employees + +def on_doctype_update(): + frappe.db.add_index("Employee", ["lft", "rgt"]) + +def has_user_permission_for_employee(user_name, employee_name): + return frappe.db.exists({ + 'doctype': 'User Permission', + 'user': user_name, + 'allow': 'Employee', + 'for_value': employee_name + }) + +def has_upload_permission(doc, ptype='read', user=None): + if not user: + user = frappe.session.user + if get_doc_permissions(doc, user=user, ptype=ptype).get(ptype): + return True + return doc.user_id == user diff --git a/erpnext/hr/doctype/employee/test_employee.py b/erpnext/hr/doctype/employee/test_employee.py new file mode 100644 index 00000000000..5feb6de8f2b --- /dev/null +++ b/erpnext/hr/doctype/employee/test_employee.py @@ -0,0 +1,82 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +import erpnext +import unittest +import frappe.utils +from erpnext.hr.doctype.employee.employee import InactiveEmployeeStatusError + +test_records = frappe.get_test_records('Employee') + +class TestEmployee(unittest.TestCase): + def test_employee_status_left(self): + employee1 = make_employee("test_employee_1@company.com") + employee2 = make_employee("test_employee_2@company.com") + employee1_doc = frappe.get_doc("Employee", employee1) + employee2_doc = frappe.get_doc("Employee", employee2) + employee2_doc.reload() + employee2_doc.reports_to = employee1_doc.name + employee2_doc.save() + employee1_doc.reload() + employee1_doc.status = 'Left' + self.assertRaises(InactiveEmployeeStatusError, employee1_doc.save) + + def test_employee_status_inactive(self): + from erpnext.payroll.doctype.salary_structure.test_salary_structure import make_salary_structure + from erpnext.payroll.doctype.salary_structure.salary_structure import make_salary_slip + from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list + + employee = make_employee("test_employee_status@company.com") + employee_doc = frappe.get_doc("Employee", employee) + employee_doc.status = "Inactive" + employee_doc.save() + employee_doc.reload() + + make_holiday_list() + frappe.db.set_value("Company", erpnext.get_default_company(), "default_holiday_list", "Salary Slip Test Holiday List") + + frappe.db.sql("""delete from `tabSalary Structure` where name='Test Inactive Employee Salary Slip'""") + salary_structure = make_salary_structure("Test Inactive Employee Salary Slip", "Monthly", + employee=employee_doc.name, company=employee_doc.company) + salary_slip = make_salary_slip(salary_structure.name, employee=employee_doc.name) + + self.assertRaises(InactiveEmployeeStatusError, salary_slip.save) + + def tearDown(self): + frappe.db.rollback() + +def make_employee(user, company=None, **kwargs): + if not frappe.db.get_value("User", user): + frappe.get_doc({ + "doctype": "User", + "email": user, + "first_name": user, + "new_password": "password", + "roles": [{"doctype": "Has Role", "role": "Employee"}] + }).insert() + + if not frappe.db.get_value("Employee", {"user_id": user}): + employee = frappe.get_doc({ + "doctype": "Employee", + "naming_series": "EMP-", + "first_name": user, + "company": company or erpnext.get_default_company(), + "user_id": user, + "date_of_birth": "1990-05-08", + "date_of_joining": "2013-01-01", + "department": frappe.get_all("Department", fields="name")[0].name, + "gender": "Female", + "company_email": user, + "prefered_contact_email": "Company Email", + "prefered_email": user, + "status": "Active", + "employment_type": "Intern" + }) + if kwargs: + employee.update(kwargs) + employee.insert() + return employee.name + else: + frappe.db.set_value("Employee", {"employee_name":user}, "status", "Active") + return frappe.get_value("Employee", {"employee_name":user}, "name") diff --git a/erpnext/hr/doctype/hr_settings/hr_settings.json b/erpnext/hr/doctype/hr_settings/hr_settings.json new file mode 100644 index 00000000000..8aa3c0ca9f1 --- /dev/null +++ b/erpnext/hr/doctype/hr_settings/hr_settings.json @@ -0,0 +1,209 @@ +{ + "actions": [], + "creation": "2013-08-02 13:45:23", + "doctype": "DocType", + "document_type": "Other", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "employee_settings", + "retirement_age", + "emp_created_by", + "column_break_4", + "standard_working_hours", + "expense_approver_mandatory_in_expense_claim", + "reminders_section", + "send_birthday_reminders", + "column_break_9", + "send_work_anniversary_reminders", + "column_break_11", + "send_holiday_reminders", + "frequency", + "leave_settings", + "send_leave_notification", + "leave_approval_notification_template", + "leave_status_notification_template", + "role_allowed_to_create_backdated_leave_application", + "column_break_18", + "leave_approver_mandatory_in_leave_application", + "show_leaves_of_all_department_members_in_calendar", + "auto_leave_encashment", + "restrict_backdated_leave_application", + "hiring_settings", + "check_vacancies" + ], + "fields": [ + { + "fieldname": "employee_settings", + "fieldtype": "Section Break", + "label": "Employee Settings" + }, + { + "description": "Enter retirement age in years", + "fieldname": "retirement_age", + "fieldtype": "Data", + "label": "Retirement Age" + }, + { + "default": "Naming Series", + "description": "Employee records are created using the selected field", + "fieldname": "emp_created_by", + "fieldtype": "Select", + "label": "Employee Records to be created by", + "options": "Naming Series\nEmployee Number\nFull Name" + }, + { + "fieldname": "column_break_4", + "fieldtype": "Column Break" + }, + { + "default": "1", + "fieldname": "expense_approver_mandatory_in_expense_claim", + "fieldtype": "Check", + "label": "Expense Approver Mandatory In Expense Claim" + }, + { + "collapsible": 1, + "fieldname": "leave_settings", + "fieldtype": "Section Break", + "label": "Leave Settings" + }, + { + "depends_on": "eval: doc.send_leave_notification == 1", + "fieldname": "leave_approval_notification_template", + "fieldtype": "Link", + "label": "Leave Approval Notification Template", + "mandatory_depends_on": "eval: doc.send_leave_notification == 1", + "options": "Email Template" + }, + { + "depends_on": "eval: doc.send_leave_notification == 1", + "fieldname": "leave_status_notification_template", + "fieldtype": "Link", + "label": "Leave Status Notification Template", + "mandatory_depends_on": "eval: doc.send_leave_notification == 1", + "options": "Email Template" + }, + { + "fieldname": "column_break_18", + "fieldtype": "Column Break" + }, + { + "default": "1", + "fieldname": "leave_approver_mandatory_in_leave_application", + "fieldtype": "Check", + "label": "Leave Approver Mandatory In Leave Application" + }, + { + "default": "0", + "fieldname": "show_leaves_of_all_department_members_in_calendar", + "fieldtype": "Check", + "label": "Show Leaves Of All Department Members In Calendar" + }, + { + "collapsible": 1, + "fieldname": "hiring_settings", + "fieldtype": "Section Break", + "label": "Hiring Settings" + }, + { + "default": "0", + "fieldname": "check_vacancies", + "fieldtype": "Check", + "label": "Check Vacancies On Job Offer Creation" + }, + { + "default": "0", + "fieldname": "auto_leave_encashment", + "fieldtype": "Check", + "label": "Auto Leave Encashment" + }, + { + "default": "0", + "fieldname": "restrict_backdated_leave_application", + "fieldtype": "Check", + "label": "Restrict Backdated Leave Application" + }, + { + "depends_on": "eval:doc.restrict_backdated_leave_application == 1", + "fieldname": "role_allowed_to_create_backdated_leave_application", + "fieldtype": "Link", + "label": "Role Allowed to Create Backdated Leave Application", + "options": "Role" + }, + { + "default": "1", + "fieldname": "send_leave_notification", + "fieldtype": "Check", + "label": "Send Leave Notification" + }, + { + "fieldname": "standard_working_hours", + "fieldtype": "Int", + "label": "Standard Working Hours" + }, + { + "collapsible": 1, + "fieldname": "reminders_section", + "fieldtype": "Section Break", + "label": "Reminders" + }, + { + "default": "1", + "fieldname": "send_holiday_reminders", + "fieldtype": "Check", + "label": "Holidays" + }, + { + "default": "1", + "fieldname": "send_work_anniversary_reminders", + "fieldtype": "Check", + "label": "Work Anniversaries " + }, + { + "default": "Weekly", + "depends_on": "eval:doc.send_holiday_reminders", + "fieldname": "frequency", + "fieldtype": "Select", + "label": "Set the frequency for holiday reminders", + "options": "Weekly\nMonthly" + }, + { + "default": "1", + "fieldname": "send_birthday_reminders", + "fieldtype": "Check", + "label": "Birthdays" + }, + { + "fieldname": "column_break_9", + "fieldtype": "Column Break" + }, + { + "fieldname": "column_break_11", + "fieldtype": "Column Break" + } + ], + "icon": "fa fa-cog", + "idx": 1, + "issingle": 1, + "links": [], + "modified": "2021-08-24 14:54:12.834162", + "modified_by": "Administrator", + "module": "HR", + "name": "HR Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "ASC", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/hr/doctype/hr_settings/hr_settings.py b/erpnext/hr/doctype/hr_settings/hr_settings.py new file mode 100644 index 00000000000..a47409363c7 --- /dev/null +++ b/erpnext/hr/doctype/hr_settings/hr_settings.py @@ -0,0 +1,79 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +# For license information, please see license.txt + +import frappe + +from frappe.model.document import Document +from frappe.utils import format_date + +# Wether to proceed with frequency change +PROCEED_WITH_FREQUENCY_CHANGE = False + +class HRSettings(Document): + def validate(self): + self.set_naming_series() + + # Based on proceed flag + global PROCEED_WITH_FREQUENCY_CHANGE + if not PROCEED_WITH_FREQUENCY_CHANGE: + self.validate_frequency_change() + PROCEED_WITH_FREQUENCY_CHANGE = False + + def set_naming_series(self): + from erpnext.setup.doctype.naming_series.naming_series import set_by_naming_series + set_by_naming_series("Employee", "employee_number", + self.get("emp_created_by")=="Naming Series", hide_name_field=True) + + def validate_frequency_change(self): + weekly_job, monthly_job = None, None + + try: + weekly_job = frappe.get_doc( + 'Scheduled Job Type', + 'employee_reminders.send_reminders_in_advance_weekly' + ) + + monthly_job = frappe.get_doc( + 'Scheduled Job Type', + 'employee_reminders.send_reminders_in_advance_monthly' + ) + except frappe.DoesNotExistError: + return + + next_weekly_trigger = weekly_job.get_next_execution() + next_monthly_trigger = monthly_job.get_next_execution() + + if self.freq_changed_from_monthly_to_weekly(): + if next_monthly_trigger < next_weekly_trigger: + self.show_freq_change_warning(next_monthly_trigger, next_weekly_trigger) + + elif self.freq_changed_from_weekly_to_monthly(): + if next_monthly_trigger > next_weekly_trigger: + self.show_freq_change_warning(next_weekly_trigger, next_monthly_trigger) + + def freq_changed_from_weekly_to_monthly(self): + return self.has_value_changed("frequency") and self.frequency == "Monthly" + + def freq_changed_from_monthly_to_weekly(self): + return self.has_value_changed("frequency") and self.frequency == "Weekly" + + def show_freq_change_warning(self, from_date, to_date): + from_date = frappe.bold(format_date(from_date)) + to_date = frappe.bold(format_date(to_date)) + frappe.msgprint( + msg=frappe._('Employees will miss holiday reminders from {} until {}.
Do you want to proceed with this change?').format(from_date, to_date), + title='Confirm change in Frequency', + primary_action={ + 'label': frappe._('Yes, Proceed'), + 'client_action': 'erpnext.proceed_save_with_reminders_frequency_change' + }, + raise_exception=frappe.ValidationError + ) + +@frappe.whitelist() +def set_proceed_with_frequency_change(): + '''Enables proceed with frequency change''' + global PROCEED_WITH_FREQUENCY_CHANGE + PROCEED_WITH_FREQUENCY_CHANGE = True diff --git a/erpnext/hr/doctype/upload_attendance/upload_attendance.py b/erpnext/hr/doctype/upload_attendance/upload_attendance.py new file mode 100644 index 00000000000..9c765d73716 --- /dev/null +++ b/erpnext/hr/doctype/upload_attendance/upload_attendance.py @@ -0,0 +1,204 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe.utils import cstr, add_days, date_diff, getdate +from frappe import _ +from frappe.utils.csvutils import UnicodeWriter +from frappe.model.document import Document +from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee +from erpnext.hr.utils import get_holiday_dates_for_employee + +class UploadAttendance(Document): + pass + +@frappe.whitelist() +def get_template(): + if not frappe.has_permission("Attendance", "create"): + raise frappe.PermissionError + + args = frappe.local.form_dict + + if getdate(args.from_date) > getdate(args.to_date): + frappe.throw(_("To Date should be greater than From Date")) + + w = UnicodeWriter() + w = add_header(w) + + try: + w = add_data(w, args) + except Exception as e: + frappe.clear_messages() + frappe.respond_as_web_page("Holiday List Missing", html=e) + return + + # write out response as a type csv + frappe.response['result'] = cstr(w.getvalue()) + frappe.response['type'] = 'csv' + frappe.response['doctype'] = "Attendance" + +def add_header(w): + status = ", ".join((frappe.get_meta("Attendance").get_field("status").options or "").strip().split("\n")) + w.writerow(["Notes:"]) + w.writerow(["Please do not change the template headings"]) + w.writerow(["Status should be one of these values: " + status]) + w.writerow(["If you are overwriting existing attendance records, 'ID' column mandatory"]) + w.writerow(["ID", "Employee", "Employee Name", "Date", "Status", "Leave Type", + "Company", "Naming Series"]) + return w + +def add_data(w, args): + data = get_data(args) + writedata(w, data) + return w + +def get_data(args): + dates = get_dates(args) + employees = get_active_employees() + holidays = get_holidays_for_employees([employee.name for employee in employees], args["from_date"], args["to_date"]) + existing_attendance_records = get_existing_attendance_records(args) + data = [] + for date in dates: + for employee in employees: + if getdate(date) < getdate(employee.date_of_joining): + continue + if employee.relieving_date: + if getdate(date) > getdate(employee.relieving_date): + continue + existing_attendance = {} + if existing_attendance_records \ + and tuple([getdate(date), employee.name]) in existing_attendance_records \ + and getdate(employee.date_of_joining) <= getdate(date) \ + and getdate(employee.relieving_date) >= getdate(date): + existing_attendance = existing_attendance_records[tuple([getdate(date), employee.name])] + + employee_holiday_list = get_holiday_list_for_employee(employee.name) + + row = [ + existing_attendance and existing_attendance.name or "", + employee.name, employee.employee_name, date, + existing_attendance and existing_attendance.status or "", + existing_attendance and existing_attendance.leave_type or "", employee.company, + existing_attendance and existing_attendance.naming_series or get_naming_series(), + ] + if date in holidays[employee_holiday_list]: + row[4] = "Holiday" + data.append(row) + + return data + +def get_holidays_for_employees(employees, from_date, to_date): + holidays = {} + for employee in employees: + holiday_list = get_holiday_list_for_employee(employee) + holiday = get_holiday_dates_for_employee(employee, getdate(from_date), getdate(to_date)) + if holiday_list not in holidays: + holidays[holiday_list] = holiday + + return holidays + +def writedata(w, data): + for row in data: + w.writerow(row) + +def get_dates(args): + """get list of dates in between from date and to date""" + no_of_days = date_diff(add_days(args["to_date"], 1), args["from_date"]) + dates = [add_days(args["from_date"], i) for i in range(0, no_of_days)] + return dates + +def get_active_employees(): + employees = frappe.db.get_all('Employee', + fields=['name', 'employee_name', 'date_of_joining', 'company', 'relieving_date'], + filters={ + 'docstatus': ['<', 2], + 'status': 'Active' + } + ) + return employees + +def get_existing_attendance_records(args): + attendance = frappe.db.sql("""select name, attendance_date, employee, status, leave_type, naming_series + from `tabAttendance` where attendance_date between %s and %s and docstatus < 2""", + (args["from_date"], args["to_date"]), as_dict=1) + + existing_attendance = {} + for att in attendance: + existing_attendance[tuple([att.attendance_date, att.employee])] = att + + return existing_attendance + +def get_naming_series(): + series = frappe.get_meta("Attendance").get_field("naming_series").options.strip().split("\n") + if not series: + frappe.throw(_("Please setup numbering series for Attendance via Setup > Numbering Series")) + return series[0] + + +@frappe.whitelist() +def upload(): + if not frappe.has_permission("Attendance", "create"): + raise frappe.PermissionError + + from frappe.utils.csvutils import read_csv_content + rows = read_csv_content(frappe.local.uploaded_file) + if not rows: + frappe.throw(_("Please select a csv file")) + frappe.enqueue(import_attendances, rows=rows, now=True if len(rows) < 200 else False) + +def import_attendances(rows): + + def remove_holidays(rows): + rows = [ row for row in rows if row[4] != "Holiday"] + return rows + + from frappe.modules import scrub + + rows = list(filter(lambda x: x and any(x), rows)) + columns = [scrub(f) for f in rows[4]] + columns[0] = "name" + columns[3] = "attendance_date" + rows = rows[5:] + ret = [] + error = False + + rows = remove_holidays(rows) + + from frappe.utils.csvutils import check_record, import_doc + + for i, row in enumerate(rows): + if not row: continue + row_idx = i + 5 + d = frappe._dict(zip(columns, row)) + + d["doctype"] = "Attendance" + if d.name: + d["docstatus"] = frappe.db.get_value("Attendance", d.name, "docstatus") + + try: + check_record(d) + ret.append(import_doc(d, "Attendance", 1, row_idx, submit=True)) + frappe.publish_realtime('import_attendance', dict( + progress=i, + total=len(rows) + )) + except AttributeError: + pass + except Exception as e: + error = True + ret.append('Error for row (#%d) %s : %s' % (row_idx, + len(row)>1 and row[1] or "", cstr(e))) + frappe.errprint(frappe.get_traceback()) + + if error: + frappe.db.rollback() + else: + frappe.db.commit() + + frappe.publish_realtime('import_attendance', dict( + messages=ret, + error=error + )) diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py new file mode 100644 index 00000000000..9c936ab4ad0 --- /dev/null +++ b/erpnext/hr/utils.py @@ -0,0 +1,552 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import erpnext +import frappe +from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee, InactiveEmployeeStatusError +from frappe import _ +from frappe.desk.form import assign_to +from frappe.model.document import Document +from frappe.utils import (add_days, cstr, flt, format_datetime, formatdate, + get_datetime, getdate, nowdate, today, unique, get_link_to_form) + +class DuplicateDeclarationError(frappe.ValidationError): pass + + +class EmployeeBoardingController(Document): + ''' + Create the project and the task for the boarding process + Assign to the concerned person and roles as per the onboarding/separation template + ''' + def validate(self): + validate_active_employee(self.employee) + # remove the task if linked before submitting the form + if self.amended_from: + for activity in self.activities: + activity.task = '' + + def on_submit(self): + # create the project for the given employee onboarding + project_name = _(self.doctype) + " : " + if self.doctype == "Employee Onboarding": + project_name += self.job_applicant + else: + project_name += self.employee + + project = frappe.get_doc({ + "doctype": "Project", + "project_name": project_name, + "expected_start_date": self.date_of_joining if self.doctype == "Employee Onboarding" else self.resignation_letter_date, + "department": self.department, + "company": self.company + }).insert(ignore_permissions=True, ignore_mandatory=True) + + self.db_set("project", project.name) + self.db_set("boarding_status", "Pending") + self.reload() + self.create_task_and_notify_user() + + def create_task_and_notify_user(self): + # create the task for the given project and assign to the concerned person + for activity in self.activities: + if activity.task: + continue + + task = frappe.get_doc({ + "doctype": "Task", + "project": self.project, + "subject": activity.activity_name + " : " + self.employee_name, + "description": activity.description, + "department": self.department, + "company": self.company, + "task_weight": activity.task_weight + }).insert(ignore_permissions=True) + activity.db_set("task", task.name) + + users = [activity.user] if activity.user else [] + if activity.role: + user_list = frappe.db.sql_list(''' + SELECT + DISTINCT(has_role.parent) + FROM + `tabHas Role` has_role + LEFT JOIN `tabUser` user + ON has_role.parent = user.name + WHERE + has_role.parenttype = 'User' + AND user.enabled = 1 + AND has_role.role = %s + ''', activity.role) + users = unique(users + user_list) + + if "Administrator" in users: + users.remove("Administrator") + + # assign the task the users + if users: + self.assign_task_to_users(task, users) + + def assign_task_to_users(self, task, users): + for user in users: + args = { + 'assign_to': [user], + 'doctype': task.doctype, + 'name': task.name, + 'description': task.description or task.subject, + 'notify': self.notify_users_by_email + } + assign_to.add(args) + + def on_cancel(self): + # delete task project + for task in frappe.get_all("Task", filters={"project": self.project}): + frappe.delete_doc("Task", task.name, force=1) + frappe.delete_doc("Project", self.project, force=1) + self.db_set('project', '') + for activity in self.activities: + activity.db_set("task", "") + + +@frappe.whitelist() +def get_onboarding_details(parent, parenttype): + return frappe.get_all("Employee Boarding Activity", + fields=["activity_name", "role", "user", "required_for_employee_creation", "description", "task_weight"], + filters={"parent": parent, "parenttype": parenttype}, + order_by= "idx") + +@frappe.whitelist() +def get_boarding_status(project): + status = 'Pending' + if project: + doc = frappe.get_doc('Project', project) + if flt(doc.percent_complete) > 0.0 and flt(doc.percent_complete) < 100.0: + status = 'In Process' + elif flt(doc.percent_complete) == 100.0: + status = 'Completed' + return status + +def set_employee_name(doc): + if doc.employee and not doc.employee_name: + doc.employee_name = frappe.db.get_value("Employee", doc.employee, "employee_name") + +def update_employee(employee, details, date=None, cancel=False): + internal_work_history = {} + for item in details: + fieldtype = frappe.get_meta("Employee").get_field(item.fieldname).fieldtype + new_data = item.new if not cancel else item.current + if fieldtype == "Date" and new_data: + new_data = getdate(new_data) + elif fieldtype =="Datetime" and new_data: + new_data = get_datetime(new_data) + setattr(employee, item.fieldname, new_data) + if item.fieldname in ["department", "designation", "branch"]: + internal_work_history[item.fieldname] = item.new + if internal_work_history and not cancel: + internal_work_history["from_date"] = date + employee.append("internal_work_history", internal_work_history) + return employee + +@frappe.whitelist() +def get_employee_fields_label(): + fields = [] + for df in frappe.get_meta("Employee").get("fields"): + if df.fieldname in ["salutation", "user_id", "employee_number", "employment_type", + "holiday_list", "branch", "department", "designation", "grade", + "notice_number_of_days", "reports_to", "leave_policy", "company_email"]: + fields.append({"value": df.fieldname, "label": df.label}) + return fields + +@frappe.whitelist() +def get_employee_field_property(employee, fieldname): + if employee and fieldname: + field = frappe.get_meta("Employee").get_field(fieldname) + value = frappe.db.get_value("Employee", employee, fieldname) + options = field.options + if field.fieldtype == "Date": + value = formatdate(value) + elif field.fieldtype == "Datetime": + value = format_datetime(value) + return { + "value" : value, + "datatype" : field.fieldtype, + "label" : field.label, + "options" : options + } + else: + return False + +def validate_dates(doc, from_date, to_date): + date_of_joining, relieving_date = frappe.db.get_value("Employee", doc.employee, ["date_of_joining", "relieving_date"]) + if getdate(from_date) > getdate(to_date): + frappe.throw(_("To date can not be less than from date")) + elif getdate(from_date) > getdate(nowdate()): + frappe.throw(_("Future dates not allowed")) + elif date_of_joining and getdate(from_date) < getdate(date_of_joining): + frappe.throw(_("From date can not be less than employee's joining date")) + elif relieving_date and getdate(to_date) > getdate(relieving_date): + frappe.throw(_("To date can not greater than employee's relieving date")) + +def validate_overlap(doc, from_date, to_date, company = None): + query = """ + select name + from `tab{0}` + where name != %(name)s + """ + query += get_doc_condition(doc.doctype) + + if not doc.name: + # hack! if name is null, it could cause problems with != + doc.name = "New "+doc.doctype + + overlap_doc = frappe.db.sql(query.format(doc.doctype),{ + "employee": doc.get("employee"), + "from_date": from_date, + "to_date": to_date, + "name": doc.name, + "company": company + }, as_dict = 1) + + if overlap_doc: + if doc.get("employee"): + exists_for = doc.employee + if company: + exists_for = company + throw_overlap_error(doc, exists_for, overlap_doc[0].name, from_date, to_date) + +def get_doc_condition(doctype): + if doctype == "Compensatory Leave Request": + return "and employee = %(employee)s and docstatus < 2 \ + and (work_from_date between %(from_date)s and %(to_date)s \ + or work_end_date between %(from_date)s and %(to_date)s \ + or (work_from_date < %(from_date)s and work_end_date > %(to_date)s))" + elif doctype == "Leave Period": + return "and company = %(company)s and (from_date between %(from_date)s and %(to_date)s \ + or to_date between %(from_date)s and %(to_date)s \ + or (from_date < %(from_date)s and to_date > %(to_date)s))" + +def throw_overlap_error(doc, exists_for, overlap_doc, from_date, to_date): + msg = _("A {0} exists between {1} and {2} (").format(doc.doctype, + formatdate(from_date), formatdate(to_date)) \ + + """ {1}""".format(doc.doctype, overlap_doc) \ + + _(") for {0}").format(exists_for) + frappe.throw(msg) + +def validate_duplicate_exemption_for_payroll_period(doctype, docname, payroll_period, employee): + existing_record = frappe.db.exists(doctype, { + "payroll_period": payroll_period, + "employee": employee, + 'docstatus': ['<', 2], + 'name': ['!=', docname] + }) + if existing_record: + frappe.throw(_("{0} already exists for employee {1} and period {2}") + .format(doctype, employee, payroll_period), DuplicateDeclarationError) + +def validate_tax_declaration(declarations): + subcategories = [] + for d in declarations: + if d.exemption_sub_category in subcategories: + frappe.throw(_("More than one selection for {0} not allowed").format(d.exemption_sub_category)) + subcategories.append(d.exemption_sub_category) + +def get_total_exemption_amount(declarations): + exemptions = frappe._dict() + for d in declarations: + exemptions.setdefault(d.exemption_category, frappe._dict()) + category_max_amount = exemptions.get(d.exemption_category).max_amount + if not category_max_amount: + category_max_amount = frappe.db.get_value("Employee Tax Exemption Category", d.exemption_category, "max_amount") + exemptions.get(d.exemption_category).max_amount = category_max_amount + sub_category_exemption_amount = d.max_amount \ + if (d.max_amount and flt(d.amount) > flt(d.max_amount)) else d.amount + + exemptions.get(d.exemption_category).setdefault("total_exemption_amount", 0.0) + exemptions.get(d.exemption_category).total_exemption_amount += flt(sub_category_exemption_amount) + + if category_max_amount and exemptions.get(d.exemption_category).total_exemption_amount > category_max_amount: + exemptions.get(d.exemption_category).total_exemption_amount = category_max_amount + + total_exemption_amount = sum([flt(d.total_exemption_amount) for d in exemptions.values()]) + return total_exemption_amount + +@frappe.whitelist() +def get_leave_period(from_date, to_date, company): + leave_period = frappe.db.sql(""" + select name, from_date, to_date + from `tabLeave Period` + where company=%(company)s and is_active=1 + and (from_date between %(from_date)s and %(to_date)s + or to_date between %(from_date)s and %(to_date)s + or (from_date < %(from_date)s and to_date > %(to_date)s)) + """, { + "from_date": from_date, + "to_date": to_date, + "company": company + }, as_dict=1) + + if leave_period: + return leave_period + +def generate_leave_encashment(): + ''' Generates a draft leave encashment on allocation expiry ''' + from erpnext.hr.doctype.leave_encashment.leave_encashment import create_leave_encashment + + if frappe.db.get_single_value('HR Settings', 'auto_leave_encashment'): + leave_type = frappe.get_all('Leave Type', filters={'allow_encashment': 1}, fields=['name']) + leave_type=[l['name'] for l in leave_type] + + leave_allocation = frappe.get_all("Leave Allocation", filters={ + 'to_date': add_days(today(), -1), + 'leave_type': ('in', leave_type) + }, fields=['employee', 'leave_period', 'leave_type', 'to_date', 'total_leaves_allocated', 'new_leaves_allocated']) + + create_leave_encashment(leave_allocation=leave_allocation) + +def allocate_earned_leaves(): + '''Allocate earned leaves to Employees''' + e_leave_types = get_earned_leaves() + today = getdate() + + for e_leave_type in e_leave_types: + + leave_allocations = get_leave_allocations(today, e_leave_type.name) + + for allocation in leave_allocations: + + if not allocation.leave_policy_assignment and not allocation.leave_policy: + continue + + leave_policy = allocation.leave_policy if allocation.leave_policy else frappe.db.get_value( + "Leave Policy Assignment", allocation.leave_policy_assignment, ["leave_policy"]) + + annual_allocation = frappe.db.get_value("Leave Policy Detail", filters={ + 'parent': leave_policy, + 'leave_type': e_leave_type.name + }, fieldname=['annual_allocation']) + + from_date=allocation.from_date + + if e_leave_type.based_on_date_of_joining_date: + from_date = frappe.db.get_value("Employee", allocation.employee, "date_of_joining") + + if check_effective_date(from_date, today, e_leave_type.earned_leave_frequency, e_leave_type.based_on_date_of_joining_date): + update_previous_leave_allocation(allocation, annual_allocation, e_leave_type) + +def update_previous_leave_allocation(allocation, annual_allocation, e_leave_type): + earned_leaves = get_monthly_earned_leave(annual_allocation, e_leave_type.earned_leave_frequency, e_leave_type.rounding) + + allocation = frappe.get_doc('Leave Allocation', allocation.name) + new_allocation = flt(allocation.total_leaves_allocated) + flt(earned_leaves) + + if new_allocation > e_leave_type.max_leaves_allowed and e_leave_type.max_leaves_allowed > 0: + new_allocation = e_leave_type.max_leaves_allowed + + if new_allocation != allocation.total_leaves_allocated: + allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False) + today_date = today() + create_additional_leave_ledger_entry(allocation, earned_leaves, today_date) + +def get_monthly_earned_leave(annual_leaves, frequency, rounding): + earned_leaves = 0.0 + divide_by_frequency = {"Yearly": 1, "Half-Yearly": 6, "Quarterly": 4, "Monthly": 12} + if annual_leaves: + earned_leaves = flt(annual_leaves) / divide_by_frequency[frequency] + if rounding: + if rounding == "0.25": + earned_leaves = round(earned_leaves * 4) / 4 + elif rounding == "0.5": + earned_leaves = round(earned_leaves * 2) / 2 + else: + earned_leaves = round(earned_leaves) + + return earned_leaves + + +def get_leave_allocations(date, leave_type): + return frappe.db.sql("""select name, employee, from_date, to_date, leave_policy_assignment, leave_policy + from `tabLeave Allocation` + where + %s between from_date and to_date and docstatus=1 + and leave_type=%s""", + (date, leave_type), as_dict=1) + + +def get_earned_leaves(): + return frappe.get_all("Leave Type", + fields=["name", "max_leaves_allowed", "earned_leave_frequency", "rounding", "based_on_date_of_joining"], + filters={'is_earned_leave' : 1}) + +def create_additional_leave_ledger_entry(allocation, leaves, date): + ''' Create leave ledger entry for leave types ''' + allocation.new_leaves_allocated = leaves + allocation.from_date = date + allocation.unused_leaves = 0 + allocation.create_leave_ledger_entry() + +def check_effective_date(from_date, to_date, frequency, based_on_date_of_joining_date): + import calendar + from dateutil import relativedelta + + from_date = get_datetime(from_date) + to_date = get_datetime(to_date) + rd = relativedelta.relativedelta(to_date, from_date) + #last day of month + last_day = calendar.monthrange(to_date.year, to_date.month)[1] + + if (from_date.day == to_date.day and based_on_date_of_joining_date) or (not based_on_date_of_joining_date and to_date.day == last_day): + if frequency == "Monthly": + return True + elif frequency == "Quarterly" and rd.months % 3: + return True + elif frequency == "Half-Yearly" and rd.months % 6: + return True + elif frequency == "Yearly" and rd.months % 12: + return True + + if frappe.flags.in_test: + return True + + return False + + +def get_salary_assignment(employee, date): + assignment = frappe.db.sql(""" + select * from `tabSalary Structure Assignment` + where employee=%(employee)s + and docstatus = 1 + and %(on_date)s >= from_date order by from_date desc limit 1""", { + 'employee': employee, + 'on_date': date, + }, as_dict=1) + return assignment[0] if assignment else None + +def get_sal_slip_total_benefit_given(employee, payroll_period, component=False): + total_given_benefit_amount = 0 + query = """ + select sum(sd.amount) as 'total_amount' + from `tabSalary Slip` ss, `tabSalary Detail` sd + where ss.employee=%(employee)s + and ss.docstatus = 1 and ss.name = sd.parent + and sd.is_flexible_benefit = 1 and sd.parentfield = "earnings" + and sd.parenttype = "Salary Slip" + and (ss.start_date between %(start_date)s and %(end_date)s + or ss.end_date between %(start_date)s and %(end_date)s + or (ss.start_date < %(start_date)s and ss.end_date > %(end_date)s)) + """ + + if component: + query += "and sd.salary_component = %(component)s" + + sum_of_given_benefit = frappe.db.sql(query, { + 'employee': employee, + 'start_date': payroll_period.start_date, + 'end_date': payroll_period.end_date, + 'component': component + }, as_dict=True) + + if sum_of_given_benefit and flt(sum_of_given_benefit[0].total_amount) > 0: + total_given_benefit_amount = sum_of_given_benefit[0].total_amount + return total_given_benefit_amount + +def get_holiday_dates_for_employee(employee, start_date, end_date): + """return a list of holiday dates for the given employee between start_date and end_date""" + # return only date + holidays = get_holidays_for_employee(employee, start_date, end_date) + + return [cstr(h.holiday_date) for h in holidays] + + +def get_holidays_for_employee(employee, start_date, end_date, raise_exception=True, only_non_weekly=False): + """Get Holidays for a given employee + + `employee` (str) + `start_date` (str or datetime) + `end_date` (str or datetime) + `raise_exception` (bool) + `only_non_weekly` (bool) + + return: list of dicts with `holiday_date` and `description` + """ + holiday_list = get_holiday_list_for_employee(employee, raise_exception=raise_exception) + + if not holiday_list: + return [] + + filters = { + 'parent': holiday_list, + 'holiday_date': ('between', [start_date, end_date]) + } + + if only_non_weekly: + filters['weekly_off'] = False + + holidays = frappe.get_all( + 'Holiday', + fields=['description', 'holiday_date'], + filters=filters + ) + + return holidays + +@erpnext.allow_regional +def calculate_annual_eligible_hra_exemption(doc): + # Don't delete this method, used for localization + # Indian HRA Exemption Calculation + return {} + +@erpnext.allow_regional +def calculate_hra_exemption_for_period(doc): + # Don't delete this method, used for localization + # Indian HRA Exemption Calculation + return {} + +def get_previous_claimed_amount(employee, payroll_period, non_pro_rata=False, component=False): + total_claimed_amount = 0 + query = """ + select sum(claimed_amount) as 'total_amount' + from `tabEmployee Benefit Claim` + where employee=%(employee)s + and docstatus = 1 + and (claim_date between %(start_date)s and %(end_date)s) + """ + if non_pro_rata: + query += "and pay_against_benefit_claim = 1" + if component: + query += "and earning_component = %(component)s" + + sum_of_claimed_amount = frappe.db.sql(query, { + 'employee': employee, + 'start_date': payroll_period.start_date, + 'end_date': payroll_period.end_date, + 'component': component + }, as_dict=True) + if sum_of_claimed_amount and flt(sum_of_claimed_amount[0].total_amount) > 0: + total_claimed_amount = sum_of_claimed_amount[0].total_amount + return total_claimed_amount + +def share_doc_with_approver(doc, user): + # if approver does not have permissions, share + if not frappe.has_permission(doc=doc, ptype="submit", user=user): + frappe.share.add(doc.doctype, doc.name, user, submit=1, + flags={"ignore_share_permission": True}) + + frappe.msgprint(_("Shared with the user {0} with {1} access").format( + user, frappe.bold("submit"), alert=True)) + + # remove shared doc if approver changes + doc_before_save = doc.get_doc_before_save() + if doc_before_save: + approvers = { + "Leave Application": "leave_approver", + "Expense Claim": "expense_approver", + "Shift Request": "approver" + } + + approver = approvers.get(doc.doctype) + if doc_before_save.get(approver) != doc.get(approver): + frappe.share.remove(doc.doctype, doc.name, doc_before_save.get(approver)) + +def validate_active_employee(employee): + if frappe.db.get_value("Employee", employee, "status") == "Inactive": + frappe.throw(_("Transactions cannot be created for an Inactive Employee {0}.").format( + get_link_to_form("Employee", employee)), InactiveEmployeeStatusError) diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 29739a4e01f..475e9fcfab9 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -199,6 +199,11 @@ erpnext.patches.v12_0.add_document_type_field_for_italy_einvoicing erpnext.patches.v13_0.update_shipment_status erpnext.patches.v13_0.remove_attribute_field_from_item_variant_setting erpnext.patches.v13_0.set_pos_closing_as_failed +<<<<<<< HEAD +======= +erpnext.patches.v13_0.rename_stop_to_send_birthday_reminders +execute:frappe.rename_doc("Workspace", "Loan Management", "Loans", force=True) +>>>>>>> 24b2a31581 (feat: Employee reminders (#25735)) erpnext.patches.v13_0.update_timesheet_changes erpnext.patches.v13_0.add_doctype_to_sla #14-06-2021 erpnext.patches.v13_0.bill_for_rejected_quantity_in_purchase_invoice diff --git a/erpnext/patches/v13_0/rename_stop_to_send_birthday_reminders.py b/erpnext/patches/v13_0/rename_stop_to_send_birthday_reminders.py new file mode 100644 index 00000000000..1787a560254 --- /dev/null +++ b/erpnext/patches/v13_0/rename_stop_to_send_birthday_reminders.py @@ -0,0 +1,23 @@ +import frappe +from frappe.model.utils.rename_field import rename_field + +def execute(): + frappe.reload_doc('hr', 'doctype', 'hr_settings') + + try: + # Rename the field + rename_field('HR Settings', 'stop_birthday_reminders', 'send_birthday_reminders') + + # Reverse the value + old_value = frappe.db.get_single_value('HR Settings', 'send_birthday_reminders') + + frappe.db.set_value( + 'HR Settings', + 'HR Settings', + 'send_birthday_reminders', + 1 if old_value == 0 else 0 + ) + + except Exception as e: + if e.args[0] != 1054: + raise \ No newline at end of file diff --git a/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.py b/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.py new file mode 100644 index 00000000000..a1cde08a74c --- /dev/null +++ b/erpnext/payroll/doctype/employee_benefit_application/employee_benefit_application.py @@ -0,0 +1,256 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _ +from frappe.utils import date_diff, getdate, rounded, add_days, cstr, cint, flt +from frappe.model.document import Document +from erpnext.payroll.doctype.payroll_period.payroll_period import get_payroll_period_days, get_period_factor +from erpnext.payroll.doctype.salary_structure_assignment.salary_structure_assignment import get_assigned_salary_structure +from erpnext.hr.utils import get_sal_slip_total_benefit_given, get_holiday_dates_for_employee, get_previous_claimed_amount, validate_active_employee + +class EmployeeBenefitApplication(Document): + def validate(self): + validate_active_employee(self.employee) + self.validate_duplicate_on_payroll_period() + if not self.max_benefits: + self.max_benefits = get_max_benefits_remaining(self.employee, self.date, self.payroll_period) + if self.max_benefits and self.max_benefits > 0: + self.validate_max_benefit_for_component() + self.validate_prev_benefit_claim() + if self.remaining_benefit > 0: + self.validate_remaining_benefit_amount() + else: + frappe.throw(_("As per your assigned Salary Structure you cannot apply for benefits").format(self.employee)) + + def validate_prev_benefit_claim(self): + if self.employee_benefits: + for benefit in self.employee_benefits: + if benefit.pay_against_benefit_claim == 1: + payroll_period = frappe.get_doc("Payroll Period", self.payroll_period) + benefit_claimed = get_previous_claimed_amount(self.employee, payroll_period, component = benefit.earning_component) + benefit_given = get_sal_slip_total_benefit_given(self.employee, payroll_period, component = benefit.earning_component) + benefit_claim_remining = benefit_claimed - benefit_given + if benefit_claimed > 0 and benefit_claim_remining > benefit.amount: + frappe.throw(_("An amount of {0} already claimed for the component {1}, set the amount equal or greater than {2}").format( + benefit_claimed, benefit.earning_component, benefit_claim_remining)) + + def validate_remaining_benefit_amount(self): + # check salary structure earnings have flexi component (sum of max_benefit_amount) + # without pro-rata which satisfy the remaining_benefit + # else pro-rata component for the amount + # again comes the same validation and satisfy or throw + benefit_components = [] + if self.employee_benefits: + for employee_benefit in self.employee_benefits: + benefit_components.append(employee_benefit.earning_component) + salary_struct_name = get_assigned_salary_structure(self.employee, self.date) + if salary_struct_name: + non_pro_rata_amount = 0 + pro_rata_amount = 0 + salary_structure = frappe.get_doc("Salary Structure", salary_struct_name) + if salary_structure.earnings: + for earnings in salary_structure.earnings: + if earnings.is_flexible_benefit == 1 and earnings.salary_component not in benefit_components: + pay_against_benefit_claim, max_benefit_amount = frappe.db.get_value("Salary Component", earnings.salary_component, ["pay_against_benefit_claim", "max_benefit_amount"]) + if pay_against_benefit_claim != 1: + pro_rata_amount += max_benefit_amount + else: + non_pro_rata_amount += max_benefit_amount + + if pro_rata_amount == 0 and non_pro_rata_amount == 0: + frappe.throw(_("Please add the remaining benefits {0} to any of the existing component").format(self.remaining_benefit)) + elif non_pro_rata_amount > 0 and non_pro_rata_amount < rounded(self.remaining_benefit): + frappe.throw(_("You can claim only an amount of {0}, the rest amount {1} should be in the application as pro-rata component").format( + non_pro_rata_amount, self.remaining_benefit - non_pro_rata_amount)) + elif non_pro_rata_amount == 0: + frappe.throw(_("Please add the remaining benefits {0} to the application as pro-rata component").format( + self.remaining_benefit)) + + def validate_max_benefit_for_component(self): + if self.employee_benefits: + max_benefit_amount = 0 + for employee_benefit in self.employee_benefits: + self.validate_max_benefit(employee_benefit.earning_component) + max_benefit_amount += employee_benefit.amount + if max_benefit_amount > self.max_benefits: + frappe.throw(_("Maximum benefit amount of employee {0} exceeds {1}").format(self.employee, self.max_benefits)) + + def validate_max_benefit(self, earning_component_name): + max_benefit_amount = frappe.db.get_value("Salary Component", earning_component_name, "max_benefit_amount") + benefit_amount = 0 + for employee_benefit in self.employee_benefits: + if employee_benefit.earning_component == earning_component_name: + benefit_amount += employee_benefit.amount + prev_sal_slip_flexi_amount = get_sal_slip_total_benefit_given(self.employee, frappe.get_doc("Payroll Period", self.payroll_period), earning_component_name) + benefit_amount += prev_sal_slip_flexi_amount + if rounded(benefit_amount, 2) > max_benefit_amount: + frappe.throw(_("Maximum benefit amount of component {0} exceeds {1}").format(earning_component_name, max_benefit_amount)) + + def validate_duplicate_on_payroll_period(self): + application = frappe.db.exists( + "Employee Benefit Application", + { + 'employee': self.employee, + 'payroll_period': self.payroll_period, + 'docstatus': 1 + } + ) + if application: + frappe.throw(_("Employee {0} already submited an apllication {1} for the payroll period {2}").format(self.employee, application, self.payroll_period)) + +@frappe.whitelist() +def get_max_benefits(employee, on_date): + sal_struct = get_assigned_salary_structure(employee, on_date) + if sal_struct: + max_benefits = frappe.db.get_value("Salary Structure", sal_struct, "max_benefits") + if max_benefits > 0: + return max_benefits + return False + +@frappe.whitelist() +def get_max_benefits_remaining(employee, on_date, payroll_period): + max_benefits = get_max_benefits(employee, on_date) + if max_benefits and max_benefits > 0: + have_depends_on_payment_days = False + per_day_amount_total = 0 + payroll_period_days = get_payroll_period_days(on_date, on_date, employee)[1] + payroll_period_obj = frappe.get_doc("Payroll Period", payroll_period) + + # Get all salary slip flexi amount in the payroll period + prev_sal_slip_flexi_total = get_sal_slip_total_benefit_given(employee, payroll_period_obj) + + if prev_sal_slip_flexi_total > 0: + # Check salary structure hold depends_on_payment_days component + # If yes then find the amount per day of each component and find the sum + sal_struct_name = get_assigned_salary_structure(employee, on_date) + if sal_struct_name: + sal_struct = frappe.get_doc("Salary Structure", sal_struct_name) + for sal_struct_row in sal_struct.get("earnings"): + salary_component = frappe.get_doc("Salary Component", sal_struct_row.salary_component) + if salary_component.depends_on_payment_days == 1 and salary_component.pay_against_benefit_claim != 1: + have_depends_on_payment_days = True + benefit_amount = get_benefit_amount_based_on_pro_rata(sal_struct, salary_component.max_benefit_amount) + amount_per_day = benefit_amount / payroll_period_days + per_day_amount_total += amount_per_day + + # Then the sum multiply with the no of lwp in that period + # Include that amount to the prev_sal_slip_flexi_total to get the actual + if have_depends_on_payment_days and per_day_amount_total > 0: + holidays = get_holiday_dates_for_employee(employee, payroll_period_obj.start_date, on_date) + working_days = date_diff(on_date, payroll_period_obj.start_date) + 1 + leave_days = calculate_lwp(employee, payroll_period_obj.start_date, holidays, working_days) + leave_days_amount = leave_days * per_day_amount_total + prev_sal_slip_flexi_total += leave_days_amount + + return max_benefits - prev_sal_slip_flexi_total + return max_benefits + +def calculate_lwp(employee, start_date, holidays, working_days): + lwp = 0 + holidays = "','".join(holidays) + for d in range(working_days): + dt = add_days(cstr(getdate(start_date)), d) + leave = frappe.db.sql(""" + select t1.name, t1.half_day + from `tabLeave Application` t1, `tabLeave Type` t2 + where t2.name = t1.leave_type + and t2.is_lwp = 1 + and t1.docstatus = 1 + and t1.employee = %(employee)s + and CASE WHEN t2.include_holiday != 1 THEN %(dt)s not in ('{0}') and %(dt)s between from_date and to_date + WHEN t2.include_holiday THEN %(dt)s between from_date and to_date + END + """.format(holidays), {"employee": employee, "dt": dt}) + if leave: + lwp = cint(leave[0][1]) and (lwp + 0.5) or (lwp + 1) + return lwp + +def get_benefit_component_amount(employee, start_date, end_date, salary_component, sal_struct, payroll_frequency, payroll_period): + if not payroll_period: + frappe.msgprint(_("Start and end dates not in a valid Payroll Period, cannot calculate {0}") + .format(salary_component)) + return False + + # Considering there is only one application for a year + benefit_application = frappe.db.sql(""" + select name + from `tabEmployee Benefit Application` + where + payroll_period=%(payroll_period)s + and employee=%(employee)s + and docstatus = 1 + """, { + 'employee': employee, + 'payroll_period': payroll_period.name + }) + + current_benefit_amount = 0.0 + component_max_benefit, depends_on_payment_days = frappe.db.get_value("Salary Component", + salary_component, ["max_benefit_amount", "depends_on_payment_days"]) + + benefit_amount = 0 + if benefit_application: + benefit_amount = frappe.db.get_value("Employee Benefit Application Detail", + {"parent": benefit_application[0][0], "earning_component": salary_component}, "amount") + elif component_max_benefit: + benefit_amount = get_benefit_amount_based_on_pro_rata(sal_struct, component_max_benefit) + + current_benefit_amount = 0 + if benefit_amount: + total_sub_periods = get_period_factor(employee, + start_date, end_date, payroll_frequency, payroll_period, depends_on_payment_days)[0] + + current_benefit_amount = benefit_amount / total_sub_periods + + return current_benefit_amount + +def get_benefit_amount_based_on_pro_rata(sal_struct, component_max_benefit): + max_benefits_total = 0 + benefit_amount = 0 + for d in sal_struct.get("earnings"): + if d.is_flexible_benefit == 1: + component = frappe.db.get_value("Salary Component", d.salary_component, ["max_benefit_amount", "pay_against_benefit_claim"], as_dict=1) + if not component.pay_against_benefit_claim: + max_benefits_total += component.max_benefit_amount + + if max_benefits_total > 0: + benefit_amount = sal_struct.max_benefits * component.max_benefit_amount / max_benefits_total + if benefit_amount > component_max_benefit: + benefit_amount = component_max_benefit + + return benefit_amount + +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def get_earning_components(doctype, txt, searchfield, start, page_len, filters): + if len(filters) < 2: + return {} + + salary_structure = get_assigned_salary_structure(filters['employee'], filters['date']) + + if salary_structure: + return frappe.db.sql(""" + select salary_component + from `tabSalary Detail` + where parent = %s and is_flexible_benefit = 1 + order by name + """, salary_structure) + else: + frappe.throw(_("Salary Structure not found for employee {0} and date {1}") + .format(filters['employee'], filters['date'])) + +@frappe.whitelist() +def get_earning_components_max_benefits(employee, date, earning_component): + salary_structure = get_assigned_salary_structure(employee, date) + amount = frappe.db.sql(""" + select amount + from `tabSalary Detail` + where parent = %s and is_flexible_benefit = 1 + and salary_component = %s + order by name + """, salary_structure, earning_component) + + return amount if amount else 0 diff --git a/erpnext/payroll/doctype/payroll_period/payroll_period.py b/erpnext/payroll/doctype/payroll_period/payroll_period.py new file mode 100644 index 00000000000..66dec075d8f --- /dev/null +++ b/erpnext/payroll/doctype/payroll_period/payroll_period.py @@ -0,0 +1,108 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _ +from frappe.utils import date_diff, getdate, formatdate, cint, month_diff, flt, add_months +from frappe.model.document import Document +from erpnext.hr.utils import get_holiday_dates_for_employee + +class PayrollPeriod(Document): + def validate(self): + self.validate_dates() + self.validate_overlap() + + def validate_dates(self): + if getdate(self.start_date) > getdate(self.end_date): + frappe.throw(_("End date can not be less than start date")) + + def validate_overlap(self): + query = """ + select name + from `tab{0}` + where name != %(name)s + and company = %(company)s and (start_date between %(start_date)s and %(end_date)s \ + or end_date between %(start_date)s and %(end_date)s \ + or (start_date < %(start_date)s and end_date > %(end_date)s)) + """ + if not self.name: + # hack! if name is null, it could cause problems with != + self.name = "New "+self.doctype + + overlap_doc = frappe.db.sql(query.format(self.doctype),{ + "start_date": self.start_date, + "end_date": self.end_date, + "name": self.name, + "company": self.company + }, as_dict = 1) + + if overlap_doc: + msg = _("A {0} exists between {1} and {2} (").format(self.doctype, + formatdate(self.start_date), formatdate(self.end_date)) \ + + """ {1}""".format(self.doctype, overlap_doc[0].name) \ + + _(") for {0}").format(self.company) + frappe.throw(msg) + +def get_payroll_period_days(start_date, end_date, employee, company=None): + if not company: + company = frappe.db.get_value("Employee", employee, "company") + payroll_period = frappe.db.sql(""" + select name, start_date, end_date + from `tabPayroll Period` + where + company=%(company)s + and %(start_date)s between start_date and end_date + and %(end_date)s between start_date and end_date + """, { + 'company': company, + 'start_date': start_date, + 'end_date': end_date + }) + + if len(payroll_period) > 0: + actual_no_of_days = date_diff(getdate(payroll_period[0][2]), getdate(payroll_period[0][1])) + 1 + working_days = actual_no_of_days + if not cint(frappe.db.get_value("Payroll Settings", None, "include_holidays_in_total_working_days")): + holidays = get_holiday_dates_for_employee(employee, getdate(payroll_period[0][1]), getdate(payroll_period[0][2])) + working_days -= len(holidays) + return payroll_period[0][0], working_days, actual_no_of_days + return False, False, False + +def get_payroll_period(from_date, to_date, company): + payroll_period = frappe.db.sql(""" + select name, start_date, end_date + from `tabPayroll Period` + where start_date<=%s and end_date>= %s and company=%s + """, (from_date, to_date, company), as_dict=1) + + return payroll_period[0] if payroll_period else None + +def get_period_factor(employee, start_date, end_date, payroll_frequency, payroll_period, depends_on_payment_days=0): + # TODO if both deduct checked update the factor to make tax consistent + period_start, period_end = payroll_period.start_date, payroll_period.end_date + joining_date, relieving_date = frappe.db.get_value("Employee", employee, ["date_of_joining", "relieving_date"]) + + if getdate(joining_date) > getdate(period_start): + period_start = joining_date + if relieving_date and getdate(relieving_date) < getdate(period_end): + period_end = relieving_date + if month_diff(period_end, start_date) > 1: + start_date = add_months(start_date, - (month_diff(period_end, start_date)+1)) + + total_sub_periods, remaining_sub_periods = 0.0, 0.0 + + if payroll_frequency == "Monthly" and not depends_on_payment_days: + total_sub_periods = month_diff(payroll_period.end_date, payroll_period.start_date) + remaining_sub_periods = month_diff(period_end, start_date) + else: + salary_days = date_diff(end_date, start_date) + 1 + + days_in_payroll_period = date_diff(payroll_period.end_date, payroll_period.start_date) + 1 + total_sub_periods = flt(days_in_payroll_period) / flt(salary_days) + + remaining_days_in_payroll_period = date_diff(period_end, start_date) + 1 + remaining_sub_periods = flt(remaining_days_in_payroll_period) / flt(salary_days) + + return total_sub_periods, remaining_sub_periods diff --git a/erpnext/payroll/doctype/salary_slip/salary_slip.py b/erpnext/payroll/doctype/salary_slip/salary_slip.py new file mode 100644 index 00000000000..fbe4742e21c --- /dev/null +++ b/erpnext/payroll/doctype/salary_slip/salary_slip.py @@ -0,0 +1,1334 @@ +# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +from __future__ import unicode_literals +import frappe, erpnext +import datetime, math + +from frappe.utils import add_days, cint, cstr, flt, getdate, rounded, date_diff, money_in_words, formatdate, get_first_day +from frappe.model.naming import make_autoname + +from frappe import msgprint, _ +from erpnext.payroll.doctype.payroll_entry.payroll_entry import get_start_end_dates +from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee +from erpnext.hr.utils import get_holiday_dates_for_employee +from erpnext.utilities.transaction_base import TransactionBase +from frappe.utils.background_jobs import enqueue +from erpnext.payroll.doctype.additional_salary.additional_salary import get_additional_salaries +from erpnext.payroll.doctype.payroll_period.payroll_period import get_period_factor, get_payroll_period +from erpnext.payroll.doctype.employee_benefit_application.employee_benefit_application import get_benefit_component_amount +from erpnext.payroll.doctype.employee_benefit_claim.employee_benefit_claim import get_benefit_claim_amount, get_last_payroll_period_benefits +from erpnext.loan_management.doctype.loan_repayment.loan_repayment import calculate_amounts, create_repayment_entry +from erpnext.accounts.utils import get_fiscal_year +from erpnext.hr.utils import validate_active_employee +from six import iteritems + +class SalarySlip(TransactionBase): + def __init__(self, *args, **kwargs): + super(SalarySlip, self).__init__(*args, **kwargs) + self.series = 'Sal Slip/{0}/.#####'.format(self.employee) + self.whitelisted_globals = { + "int": int, + "float": float, + "long": int, + "round": round, + "date": datetime.date, + "getdate": getdate + } + + def autoname(self): + self.name = make_autoname(self.series) + + def validate(self): + self.status = self.get_status() + validate_active_employee(self.employee) + self.validate_dates() + self.check_existing() + if not self.salary_slip_based_on_timesheet: + self.get_date_details() + + if not (len(self.get("earnings")) or len(self.get("deductions"))): + # get details from salary structure + self.get_emp_and_working_day_details() + else: + self.get_working_days_details(lwp = self.leave_without_pay) + + self.calculate_net_pay() + self.compute_year_to_date() + self.compute_month_to_date() + self.compute_component_wise_year_to_date() + self.add_leave_balances() + + if frappe.db.get_single_value("Payroll Settings", "max_working_hours_against_timesheet"): + max_working_hours = frappe.db.get_single_value("Payroll Settings", "max_working_hours_against_timesheet") + if self.salary_slip_based_on_timesheet and (self.total_working_hours > int(max_working_hours)): + frappe.msgprint(_("Total working hours should not be greater than max working hours {0}"). + format(max_working_hours), alert=True) + + def set_net_total_in_words(self): + doc_currency = self.currency + company_currency = erpnext.get_company_currency(self.company) + total = self.net_pay if self.is_rounding_total_disabled() else self.rounded_total + base_total = self.base_net_pay if self.is_rounding_total_disabled() else self.base_rounded_total + self.total_in_words = money_in_words(total, doc_currency) + self.base_total_in_words = money_in_words(base_total, company_currency) + + def on_submit(self): + if self.net_pay < 0: + frappe.throw(_("Net Pay cannot be less than 0")) + else: + self.set_status() + self.update_status(self.name) + self.make_loan_repayment_entry() + if (frappe.db.get_single_value("Payroll Settings", "email_salary_slip_to_employee")) and not frappe.flags.via_payroll_entry: + self.email_salary_slip() + + self.update_payment_status_for_gratuity() + + def update_payment_status_for_gratuity(self): + add_salary = frappe.db.get_all("Additional Salary", + filters = { + "payroll_date": ("BETWEEN", [self.start_date, self.end_date]), + "employee": self.employee, + "ref_doctype": "Gratuity", + "docstatus": 1, + }, fields = ["ref_docname", "name"], limit=1) + + if len(add_salary): + status = "Paid" if self.docstatus == 1 else "Unpaid" + if add_salary[0].name in [data.additional_salary for data in self.earnings]: + frappe.db.set_value("Gratuity", add_salary.ref_docname, "status", status) + + def on_cancel(self): + self.set_status() + self.update_status() + self.update_payment_status_for_gratuity() + self.cancel_loan_repayment_entry() + + def on_trash(self): + from frappe.model.naming import revert_series_if_last + revert_series_if_last(self.series, self.name) + + def get_status(self): + if self.docstatus == 0: + status = "Draft" + elif self.docstatus == 1: + status = "Submitted" + elif self.docstatus == 2: + status = "Cancelled" + return status + + def validate_dates(self, joining_date=None, relieving_date=None): + if date_diff(self.end_date, self.start_date) < 0: + frappe.throw(_("To date cannot be before From date")) + + if not joining_date: + joining_date, relieving_date = frappe.get_cached_value( + "Employee", + self.employee, + ("date_of_joining", "relieving_date") + ) + + if date_diff(self.end_date, joining_date) < 0: + frappe.throw(_("Cannot create Salary Slip for Employee joining after Payroll Period")) + + if relieving_date and date_diff(relieving_date, self.start_date) < 0: + frappe.throw(_("Cannot create Salary Slip for Employee who has left before Payroll Period")) + + def is_rounding_total_disabled(self): + return cint(frappe.db.get_single_value("Payroll Settings", "disable_rounded_total")) + + def check_existing(self): + if not self.salary_slip_based_on_timesheet: + cond = "" + if self.payroll_entry: + cond += "and payroll_entry = '{0}'".format(self.payroll_entry) + ret_exist = frappe.db.sql("""select name from `tabSalary Slip` + where start_date = %s and end_date = %s and docstatus != 2 + and employee = %s and name != %s {0}""".format(cond), + (self.start_date, self.end_date, self.employee, self.name)) + if ret_exist: + self.employee = '' + frappe.throw(_("Salary Slip of employee {0} already created for this period").format(self.employee)) + else: + for data in self.timesheets: + if frappe.db.get_value('Timesheet', data.time_sheet, 'status') == 'Payrolled': + frappe.throw(_("Salary Slip of employee {0} already created for time sheet {1}").format(self.employee, data.time_sheet)) + + def get_date_details(self): + if not self.end_date: + date_details = get_start_end_dates(self.payroll_frequency, self.start_date or self.posting_date) + self.start_date = date_details.start_date + self.end_date = date_details.end_date + + @frappe.whitelist() + def get_emp_and_working_day_details(self): + '''First time, load all the components from salary structure''' + if self.employee: + self.set("earnings", []) + self.set("deductions", []) + + if not self.salary_slip_based_on_timesheet: + self.get_date_details() + + joining_date, relieving_date = frappe.get_cached_value( + "Employee", + self.employee, + ("date_of_joining", "relieving_date") + ) + + self.validate_dates(joining_date, relieving_date) + + #getin leave details + self.get_working_days_details(joining_date, relieving_date) + struct = self.check_sal_struct(joining_date, relieving_date) + + if struct: + self._salary_structure_doc = frappe.get_doc('Salary Structure', struct) + self.salary_slip_based_on_timesheet = self._salary_structure_doc.salary_slip_based_on_timesheet or 0 + self.set_time_sheet() + self.pull_sal_struct() + ps = frappe.db.get_value("Payroll Settings", None, ["payroll_based_on","consider_unmarked_attendance_as"], as_dict=1) + return [ps.payroll_based_on, ps.consider_unmarked_attendance_as] + + def set_time_sheet(self): + if self.salary_slip_based_on_timesheet: + self.set("timesheets", []) + timesheets = frappe.db.sql(""" select * from `tabTimesheet` where employee = %(employee)s and start_date BETWEEN %(start_date)s AND %(end_date)s and (status = 'Submitted' or + status = 'Billed')""", {'employee': self.employee, 'start_date': self.start_date, 'end_date': self.end_date}, as_dict=1) + + for data in timesheets: + self.append('timesheets', { + 'time_sheet': data.name, + 'working_hours': data.total_hours + }) + + def check_sal_struct(self, joining_date, relieving_date): + cond = """and sa.employee=%(employee)s and (sa.from_date <= %(start_date)s or + sa.from_date <= %(end_date)s or sa.from_date <= %(joining_date)s)""" + if self.payroll_frequency: + cond += """and ss.payroll_frequency = '%(payroll_frequency)s'""" % {"payroll_frequency": self.payroll_frequency} + + st_name = frappe.db.sql(""" + select sa.salary_structure + from `tabSalary Structure Assignment` sa join `tabSalary Structure` ss + where sa.salary_structure=ss.name + and sa.docstatus = 1 and ss.docstatus = 1 and ss.is_active ='Yes' %s + order by sa.from_date desc + limit 1 + """ %cond, {'employee': self.employee, 'start_date': self.start_date, + 'end_date': self.end_date, 'joining_date': joining_date}) + + if st_name: + self.salary_structure = st_name[0][0] + return self.salary_structure + + else: + self.salary_structure = None + frappe.msgprint(_("No active or default Salary Structure found for employee {0} for the given dates") + .format(self.employee), title=_('Salary Structure Missing')) + + def pull_sal_struct(self): + from erpnext.payroll.doctype.salary_structure.salary_structure import make_salary_slip + + if self.salary_slip_based_on_timesheet: + self.salary_structure = self._salary_structure_doc.name + self.hour_rate = self._salary_structure_doc.hour_rate + self.base_hour_rate = flt(self.hour_rate) * flt(self.exchange_rate) + self.total_working_hours = sum([d.working_hours or 0.0 for d in self.timesheets]) or 0.0 + wages_amount = self.hour_rate * self.total_working_hours + + self.add_earning_for_hourly_wages(self, self._salary_structure_doc.salary_component, wages_amount) + + make_salary_slip(self._salary_structure_doc.name, self) + + def get_working_days_details(self, joining_date=None, relieving_date=None, lwp=None, for_preview=0): + payroll_based_on = frappe.db.get_value("Payroll Settings", None, "payroll_based_on") + include_holidays_in_total_working_days = frappe.db.get_single_value("Payroll Settings", "include_holidays_in_total_working_days") + + working_days = date_diff(self.end_date, self.start_date) + 1 + if for_preview: + self.total_working_days = working_days + self.payment_days = working_days + return + + holidays = self.get_holidays_for_employee(self.start_date, self.end_date) + + if not cint(include_holidays_in_total_working_days): + working_days -= len(holidays) + if working_days < 0: + frappe.throw(_("There are more holidays than working days this month.")) + + if not payroll_based_on: + frappe.throw(_("Please set Payroll based on in Payroll settings")) + + if payroll_based_on == "Attendance": + actual_lwp, absent = self.calculate_lwp_ppl_and_absent_days_based_on_attendance(holidays) + self.absent_days = absent + else: + actual_lwp = self.calculate_lwp_or_ppl_based_on_leave_application(holidays, working_days) + + if not lwp: + lwp = actual_lwp + elif lwp != actual_lwp: + frappe.msgprint(_("Leave Without Pay does not match with approved {} records") + .format(payroll_based_on)) + + self.leave_without_pay = lwp + self.total_working_days = working_days + + payment_days = self.get_payment_days(joining_date, + relieving_date, include_holidays_in_total_working_days) + + if flt(payment_days) > flt(lwp): + self.payment_days = flt(payment_days) - flt(lwp) + + if payroll_based_on == "Attendance": + self.payment_days -= flt(absent) + + unmarked_days = self.get_unmarked_days() + consider_unmarked_attendance_as = frappe.db.get_value("Payroll Settings", None, "consider_unmarked_attendance_as") or "Present" + + if payroll_based_on == "Attendance" and consider_unmarked_attendance_as =="Absent": + self.absent_days += unmarked_days #will be treated as absent + self.payment_days -= unmarked_days + if include_holidays_in_total_working_days: + for holiday in holidays: + if not frappe.db.exists("Attendance", {"employee": self.employee, "attendance_date": holiday, "docstatus": 1 }): + self.payment_days += 1 + else: + self.payment_days = 0 + + def get_unmarked_days(self): + marked_days = frappe.get_all("Attendance", filters = { + "attendance_date": ["between", [self.start_date, self.end_date]], + "employee": self.employee, + "docstatus": 1 + }, fields = ["COUNT(*) as marked_days"])[0].marked_days + + return self.total_working_days - marked_days + + + def get_payment_days(self, joining_date, relieving_date, include_holidays_in_total_working_days): + if not joining_date: + joining_date, relieving_date = frappe.get_cached_value("Employee", self.employee, + ["date_of_joining", "relieving_date"]) + + start_date = getdate(self.start_date) + if joining_date: + if getdate(self.start_date) <= joining_date <= getdate(self.end_date): + start_date = joining_date + elif joining_date > getdate(self.end_date): + return + + end_date = getdate(self.end_date) + if relieving_date: + if getdate(self.start_date) <= relieving_date <= getdate(self.end_date): + end_date = relieving_date + elif relieving_date < getdate(self.start_date): + frappe.throw(_("Employee relieved on {0} must be set as 'Left'") + .format(relieving_date)) + + payment_days = date_diff(end_date, start_date) + 1 + + if not cint(include_holidays_in_total_working_days): + holidays = self.get_holidays_for_employee(start_date, end_date) + payment_days -= len(holidays) + + return payment_days + + def get_holidays_for_employee(self, start_date, end_date): + return get_holiday_dates_for_employee(self.employee, start_date, end_date) + + def calculate_lwp_or_ppl_based_on_leave_application(self, holidays, working_days): + lwp = 0 + holidays = "','".join(holidays) + daily_wages_fraction_for_half_day = \ + flt(frappe.db.get_value("Payroll Settings", None, "daily_wages_fraction_for_half_day")) or 0.5 + + for d in range(working_days): + dt = add_days(cstr(getdate(self.start_date)), d) + leave = frappe.db.sql(""" + SELECT t1.name, + CASE WHEN (t1.half_day_date = %(dt)s or t1.to_date = t1.from_date) + THEN t1.half_day else 0 END, + t2.is_ppl, + t2.fraction_of_daily_salary_per_leave + FROM `tabLeave Application` t1, `tabLeave Type` t2 + WHERE t2.name = t1.leave_type + AND (t2.is_lwp = 1 or t2.is_ppl = 1) + AND t1.docstatus = 1 + AND t1.employee = %(employee)s + AND ifnull(t1.salary_slip, '') = '' + AND CASE + WHEN t2.include_holiday != 1 + THEN %(dt)s not in ('{0}') and %(dt)s between from_date and to_date + WHEN t2.include_holiday + THEN %(dt)s between from_date and to_date + END + """.format(holidays), {"employee": self.employee, "dt": dt}) + + if leave: + equivalent_lwp_count = 0 + is_half_day_leave = cint(leave[0][1]) + is_partially_paid_leave = cint(leave[0][2]) + fraction_of_daily_salary_per_leave = flt(leave[0][3]) + + equivalent_lwp_count = (1 - daily_wages_fraction_for_half_day) if is_half_day_leave else 1 + + if is_partially_paid_leave: + equivalent_lwp_count *= fraction_of_daily_salary_per_leave if fraction_of_daily_salary_per_leave else 1 + + lwp += equivalent_lwp_count + + return lwp + + def calculate_lwp_ppl_and_absent_days_based_on_attendance(self, holidays): + lwp = 0 + absent = 0 + + daily_wages_fraction_for_half_day = \ + flt(frappe.db.get_value("Payroll Settings", None, "daily_wages_fraction_for_half_day")) or 0.5 + + leave_types = frappe.get_all("Leave Type", + or_filters=[["is_ppl", "=", 1], ["is_lwp", "=", 1]], + fields =["name", "is_lwp", "is_ppl", "fraction_of_daily_salary_per_leave", "include_holiday"]) + + leave_type_map = {} + for leave_type in leave_types: + leave_type_map[leave_type.name] = leave_type + + attendances = frappe.db.sql(''' + SELECT attendance_date, status, leave_type + FROM `tabAttendance` + WHERE + status in ("Absent", "Half Day", "On leave") + AND employee = %s + AND docstatus = 1 + AND attendance_date between %s and %s + ''', values=(self.employee, self.start_date, self.end_date), as_dict=1) + + for d in attendances: + if d.status in ('Half Day', 'On Leave') and d.leave_type and d.leave_type not in leave_type_map.keys(): + continue + + if formatdate(d.attendance_date, "yyyy-mm-dd") in holidays: + if d.status == "Absent" or \ + (d.leave_type and d.leave_type in leave_type_map.keys() and not leave_type_map[d.leave_type]['include_holiday']): + continue + + if d.leave_type: + fraction_of_daily_salary_per_leave = leave_type_map[d.leave_type]["fraction_of_daily_salary_per_leave"] + + if d.status == "Half Day": + equivalent_lwp = (1 - daily_wages_fraction_for_half_day) + + if d.leave_type in leave_type_map.keys() and leave_type_map[d.leave_type]["is_ppl"]: + equivalent_lwp *= fraction_of_daily_salary_per_leave if fraction_of_daily_salary_per_leave else 1 + lwp += equivalent_lwp + elif d.status == "On Leave" and d.leave_type and d.leave_type in leave_type_map.keys(): + equivalent_lwp = 1 + if leave_type_map[d.leave_type]["is_ppl"]: + equivalent_lwp *= fraction_of_daily_salary_per_leave if fraction_of_daily_salary_per_leave else 1 + lwp += equivalent_lwp + elif d.status == "Absent": + absent += 1 + return lwp, absent + + def add_earning_for_hourly_wages(self, doc, salary_component, amount): + row_exists = False + for row in doc.earnings: + if row.salary_component == salary_component: + row.amount = amount + row_exists = True + break + + if not row_exists: + wages_row = { + "salary_component": salary_component, + "abbr": frappe.db.get_value("Salary Component", salary_component, "salary_component_abbr"), + "amount": self.hour_rate * self.total_working_hours, + "default_amount": 0.0, + "additional_amount": 0.0 + } + doc.append('earnings', wages_row) + + def calculate_net_pay(self): + if self.salary_structure: + self.calculate_component_amounts("earnings") + self.gross_pay = self.get_component_totals("earnings", depends_on_payment_days=1) + self.base_gross_pay = flt(flt(self.gross_pay) * flt(self.exchange_rate), self.precision('base_gross_pay')) + + if self.salary_structure: + self.calculate_component_amounts("deductions") + + self.set_loan_repayment() + self.set_component_amounts_based_on_payment_days() + self.set_net_pay() + + def set_net_pay(self): + self.total_deduction = self.get_component_totals("deductions") + self.base_total_deduction = flt(flt(self.total_deduction) * flt(self.exchange_rate), self.precision('base_total_deduction')) + self.net_pay = flt(self.gross_pay) - (flt(self.total_deduction) + flt(self.total_loan_repayment)) + self.rounded_total = rounded(self.net_pay) + self.base_net_pay = flt(flt(self.net_pay) * flt(self.exchange_rate), self.precision('base_net_pay')) + self.base_rounded_total = flt(rounded(self.base_net_pay), self.precision('base_net_pay')) + if self.hour_rate: + self.base_hour_rate = flt(flt(self.hour_rate) * flt(self.exchange_rate), self.precision('base_hour_rate')) + self.set_net_total_in_words() + + def calculate_component_amounts(self, component_type): + if not getattr(self, '_salary_structure_doc', None): + self._salary_structure_doc = frappe.get_doc('Salary Structure', self.salary_structure) + + payroll_period = get_payroll_period(self.start_date, self.end_date, self.company) + + self.add_structure_components(component_type) + self.add_additional_salary_components(component_type) + if component_type == "earnings": + self.add_employee_benefits(payroll_period) + else: + self.add_tax_components(payroll_period) + + def add_structure_components(self, component_type): + data = self.get_data_for_eval() + for struct_row in self._salary_structure_doc.get(component_type): + amount = self.eval_condition_and_formula(struct_row, data) + if amount and struct_row.statistical_component == 0: + self.update_component_row(struct_row, amount, component_type) + + def get_data_for_eval(self): + '''Returns data for evaluating formula''' + data = frappe._dict() + employee = frappe.get_doc("Employee", self.employee).as_dict() + + start_date = getdate(self.start_date) + date_to_validate = ( + employee.date_of_joining + if employee.date_of_joining > start_date + else start_date + ) + + salary_structure_assignment = frappe.get_value( + "Salary Structure Assignment", + { + "employee": self.employee, + "salary_structure": self.salary_structure, + "from_date": ("<=", date_to_validate), + "docstatus": 1, + }, + "*", + order_by="from_date desc", + as_dict=True, + ) + + if not salary_structure_assignment: + frappe.throw( + _("Please assign a Salary Structure for Employee {0} " + "applicable from or before {1} first").format( + frappe.bold(self.employee_name), + frappe.bold(formatdate(date_to_validate)), + ) + ) + + data.update(salary_structure_assignment) + data.update(employee) + data.update(self.as_dict()) + + # set values for components + salary_components = frappe.get_all("Salary Component", fields=["salary_component_abbr"]) + for sc in salary_components: + data.setdefault(sc.salary_component_abbr, 0) + + for key in ('earnings', 'deductions'): + for d in self.get(key): + data[d.abbr] = d.amount + + return data + + def eval_condition_and_formula(self, d, data): + try: + condition = d.condition.strip().replace("\n", " ") if d.condition else None + if condition: + if not frappe.safe_eval(condition, self.whitelisted_globals, data): + return None + amount = d.amount + if d.amount_based_on_formula: + formula = d.formula.strip().replace("\n", " ") if d.formula else None + if formula: + amount = flt(frappe.safe_eval(formula, self.whitelisted_globals, data), d.precision("amount")) + if amount: + data[d.abbr] = amount + + return amount + + except NameError as err: + frappe.throw(_("{0}
This error can be due to missing or deleted field.").format(err), + title=_("Name error")) + except SyntaxError as err: + frappe.throw(_("Syntax error in formula or condition: {0}").format(err)) + except Exception as e: + frappe.throw(_("Error in formula or condition: {0}").format(e)) + raise + + def add_employee_benefits(self, payroll_period): + for struct_row in self._salary_structure_doc.get("earnings"): + if struct_row.is_flexible_benefit == 1: + if frappe.db.get_value("Salary Component", struct_row.salary_component, "pay_against_benefit_claim") != 1: + benefit_component_amount = get_benefit_component_amount(self.employee, self.start_date, self.end_date, + struct_row.salary_component, self._salary_structure_doc, self.payroll_frequency, payroll_period) + if benefit_component_amount: + self.update_component_row(struct_row, benefit_component_amount, "earnings") + else: + benefit_claim_amount = get_benefit_claim_amount(self.employee, self.start_date, self.end_date, struct_row.salary_component) + if benefit_claim_amount: + self.update_component_row(struct_row, benefit_claim_amount, "earnings") + + self.adjust_benefits_in_last_payroll_period(payroll_period) + + def adjust_benefits_in_last_payroll_period(self, payroll_period): + if payroll_period: + if (getdate(payroll_period.end_date) <= getdate(self.end_date)): + last_benefits = get_last_payroll_period_benefits(self.employee, self.start_date, self.end_date, + payroll_period, self._salary_structure_doc) + if last_benefits: + for last_benefit in last_benefits: + last_benefit = frappe._dict(last_benefit) + amount = last_benefit.amount + self.update_component_row(frappe._dict(last_benefit.struct_row), amount, "earnings") + + def add_additional_salary_components(self, component_type): + additional_salaries = get_additional_salaries(self.employee, + self.start_date, self.end_date, component_type) + + for additional_salary in additional_salaries: + self.update_component_row( + get_salary_component_data(additional_salary.component), + additional_salary.amount, + component_type, + additional_salary + ) + + def add_tax_components(self, payroll_period): + # Calculate variable_based_on_taxable_salary after all components updated in salary slip + tax_components, other_deduction_components = [], [] + for d in self._salary_structure_doc.get("deductions"): + if d.variable_based_on_taxable_salary == 1 and not d.formula and not flt(d.amount): + tax_components.append(d.salary_component) + else: + other_deduction_components.append(d.salary_component) + + if not tax_components: + tax_components = [d.name for d in frappe.get_all("Salary Component", filters={"variable_based_on_taxable_salary": 1}) + if d.name not in other_deduction_components] + + for d in tax_components: + tax_amount = self.calculate_variable_based_on_taxable_salary(d, payroll_period) + tax_row = get_salary_component_data(d) + self.update_component_row(tax_row, tax_amount, "deductions") + + def update_component_row(self, component_data, amount, component_type, additional_salary=None): + component_row = None + for d in self.get(component_type): + if d.salary_component != component_data.salary_component: + continue + + if ( + ( + not d.additional_salary + and (not additional_salary or additional_salary.overwrite) + ) or ( + additional_salary + and additional_salary.name == d.additional_salary + ) + ): + component_row = d + break + + if additional_salary and additional_salary.overwrite: + # Additional Salary with overwrite checked, remove default rows of same component + self.set(component_type, [ + d for d in self.get(component_type) + if d.salary_component != component_data.salary_component + or (d.additional_salary and additional_salary.name != d.additional_salary) + or d == component_row + ]) + + if not component_row: + if not amount: + return + + component_row = self.append(component_type) + for attr in ( + 'depends_on_payment_days', 'salary_component', + 'do_not_include_in_total', 'is_tax_applicable', + 'is_flexible_benefit', 'variable_based_on_taxable_salary', + 'exempted_from_income_tax' + ): + component_row.set(attr, component_data.get(attr)) + + abbr = component_data.get('abbr') or component_data.get('salary_component_abbr') + component_row.set('abbr', abbr) + + if additional_salary: + if additional_salary.overwrite: + component_row.additional_amount = flt(flt(amount) - flt(component_row.get("default_amount", 0)), + component_row.precision("additional_amount")) + else: + component_row.default_amount = 0 + component_row.additional_amount = amount + + component_row.additional_salary = additional_salary.name + component_row.deduct_full_tax_on_selected_payroll_date = \ + additional_salary.deduct_full_tax_on_selected_payroll_date + else: + component_row.default_amount = amount + component_row.additional_amount = 0 + component_row.deduct_full_tax_on_selected_payroll_date = \ + component_data.deduct_full_tax_on_selected_payroll_date + + component_row.amount = amount + + def calculate_variable_based_on_taxable_salary(self, tax_component, payroll_period): + if not payroll_period: + frappe.msgprint(_("Start and end dates not in a valid Payroll Period, cannot calculate {0}.") + .format(tax_component)) + return + + # Deduct taxes forcefully for unsubmitted tax exemption proof and unclaimed benefits in the last period + if payroll_period.end_date <= getdate(self.end_date): + self.deduct_tax_for_unsubmitted_tax_exemption_proof = 1 + self.deduct_tax_for_unclaimed_employee_benefits = 1 + + return self.calculate_variable_tax(payroll_period, tax_component) + + def calculate_variable_tax(self, payroll_period, tax_component): + # get Tax slab from salary structure assignment for the employee and payroll period + tax_slab = self.get_income_tax_slabs(payroll_period) + + # get remaining numbers of sub-period (period for which one salary is processed) + remaining_sub_periods = get_period_factor(self.employee, + self.start_date, self.end_date, self.payroll_frequency, payroll_period)[1] + # get taxable_earnings, paid_taxes for previous period + previous_taxable_earnings = self.get_taxable_earnings_for_prev_period(payroll_period.start_date, + self.start_date, tax_slab.allow_tax_exemption) + previous_total_paid_taxes = self.get_tax_paid_in_period(payroll_period.start_date, self.start_date, tax_component) + + # get taxable_earnings for current period (all days) + current_taxable_earnings = self.get_taxable_earnings(tax_slab.allow_tax_exemption) + future_structured_taxable_earnings = current_taxable_earnings.taxable_earnings * (math.ceil(remaining_sub_periods) - 1) + + # get taxable_earnings, addition_earnings for current actual payment days + current_taxable_earnings_for_payment_days = self.get_taxable_earnings(tax_slab.allow_tax_exemption, based_on_payment_days=1) + current_structured_taxable_earnings = current_taxable_earnings_for_payment_days.taxable_earnings + current_additional_earnings = current_taxable_earnings_for_payment_days.additional_income + current_additional_earnings_with_full_tax = current_taxable_earnings_for_payment_days.additional_income_with_full_tax + + # Get taxable unclaimed benefits + unclaimed_taxable_benefits = 0 + if self.deduct_tax_for_unclaimed_employee_benefits: + unclaimed_taxable_benefits = self.calculate_unclaimed_taxable_benefits(payroll_period) + unclaimed_taxable_benefits += current_taxable_earnings_for_payment_days.flexi_benefits + + # Total exemption amount based on tax exemption declaration + total_exemption_amount = self.get_total_exemption_amount(payroll_period, tax_slab) + + #Employee Other Incomes + other_incomes = self.get_income_form_other_sources(payroll_period) or 0.0 + + # Total taxable earnings including additional and other incomes + total_taxable_earnings = previous_taxable_earnings + current_structured_taxable_earnings + future_structured_taxable_earnings \ + + current_additional_earnings + other_incomes + unclaimed_taxable_benefits - total_exemption_amount + + # Total taxable earnings without additional earnings with full tax + total_taxable_earnings_without_full_tax_addl_components = total_taxable_earnings - current_additional_earnings_with_full_tax + + # Structured tax amount + total_structured_tax_amount = self.calculate_tax_by_tax_slab( + total_taxable_earnings_without_full_tax_addl_components, tax_slab) + current_structured_tax_amount = (total_structured_tax_amount - previous_total_paid_taxes) / remaining_sub_periods + + # Total taxable earnings with additional earnings with full tax + full_tax_on_additional_earnings = 0.0 + if current_additional_earnings_with_full_tax: + total_tax_amount = self.calculate_tax_by_tax_slab(total_taxable_earnings, tax_slab) + full_tax_on_additional_earnings = total_tax_amount - total_structured_tax_amount + + current_tax_amount = current_structured_tax_amount + full_tax_on_additional_earnings + if flt(current_tax_amount) < 0: + current_tax_amount = 0 + + return current_tax_amount + + def get_income_tax_slabs(self, payroll_period): + income_tax_slab, ss_assignment_name = frappe.db.get_value("Salary Structure Assignment", + {"employee": self.employee, "salary_structure": self.salary_structure, "docstatus": 1}, ["income_tax_slab", 'name']) + + if not income_tax_slab: + frappe.throw(_("Income Tax Slab not set in Salary Structure Assignment: {0}").format(ss_assignment_name)) + + income_tax_slab_doc = frappe.get_doc("Income Tax Slab", income_tax_slab) + if income_tax_slab_doc.disabled: + frappe.throw(_("Income Tax Slab: {0} is disabled").format(income_tax_slab)) + + if getdate(income_tax_slab_doc.effective_from) > getdate(payroll_period.start_date): + frappe.throw(_("Income Tax Slab must be effective on or before Payroll Period Start Date: {0}") + .format(payroll_period.start_date)) + + return income_tax_slab_doc + + + def get_taxable_earnings_for_prev_period(self, start_date, end_date, allow_tax_exemption=False): + taxable_earnings = frappe.db.sql(""" + select sum(sd.amount) + from + `tabSalary Detail` sd join `tabSalary Slip` ss on sd.parent=ss.name + where + sd.parentfield='earnings' + and sd.is_tax_applicable=1 + and is_flexible_benefit=0 + and ss.docstatus=1 + and ss.employee=%(employee)s + and ss.start_date between %(from_date)s and %(to_date)s + and ss.end_date between %(from_date)s and %(to_date)s + """, { + "employee": self.employee, + "from_date": start_date, + "to_date": end_date + }) + taxable_earnings = flt(taxable_earnings[0][0]) if taxable_earnings else 0 + + exempted_amount = 0 + if allow_tax_exemption: + exempted_amount = frappe.db.sql(""" + select sum(sd.amount) + from + `tabSalary Detail` sd join `tabSalary Slip` ss on sd.parent=ss.name + where + sd.parentfield='deductions' + and sd.exempted_from_income_tax=1 + and is_flexible_benefit=0 + and ss.docstatus=1 + and ss.employee=%(employee)s + and ss.start_date between %(from_date)s and %(to_date)s + and ss.end_date between %(from_date)s and %(to_date)s + """, { + "employee": self.employee, + "from_date": start_date, + "to_date": end_date + }) + exempted_amount = flt(exempted_amount[0][0]) if exempted_amount else 0 + + return taxable_earnings - exempted_amount + + def get_tax_paid_in_period(self, start_date, end_date, tax_component): + # find total_tax_paid, tax paid for benefit, additional_salary + total_tax_paid = flt(frappe.db.sql(""" + select + sum(sd.amount) + from + `tabSalary Detail` sd join `tabSalary Slip` ss on sd.parent=ss.name + where + sd.parentfield='deductions' + and sd.salary_component=%(salary_component)s + and sd.variable_based_on_taxable_salary=1 + and ss.docstatus=1 + and ss.employee=%(employee)s + and ss.start_date between %(from_date)s and %(to_date)s + and ss.end_date between %(from_date)s and %(to_date)s + """, { + "salary_component": tax_component, + "employee": self.employee, + "from_date": start_date, + "to_date": end_date + })[0][0]) + + return total_tax_paid + + def get_taxable_earnings(self, allow_tax_exemption=False, based_on_payment_days=0): + joining_date, relieving_date = frappe.get_cached_value("Employee", self.employee, + ["date_of_joining", "relieving_date"]) + + if not relieving_date: + relieving_date = getdate(self.end_date) + + if not joining_date: + frappe.throw(_("Please set the Date Of Joining for employee {0}").format(frappe.bold(self.employee_name))) + + taxable_earnings = 0 + additional_income = 0 + additional_income_with_full_tax = 0 + flexi_benefits = 0 + + for earning in self.earnings: + if based_on_payment_days: + amount, additional_amount = self.get_amount_based_on_payment_days(earning, joining_date, relieving_date) + else: + amount, additional_amount = earning.amount, earning.additional_amount + + if earning.is_tax_applicable: + if additional_amount: + taxable_earnings += (amount - additional_amount) + additional_income += additional_amount + if earning.deduct_full_tax_on_selected_payroll_date: + additional_income_with_full_tax += additional_amount + continue + + if earning.is_flexible_benefit: + flexi_benefits += amount + else: + taxable_earnings += amount + + if allow_tax_exemption: + for ded in self.deductions: + if ded.exempted_from_income_tax: + amount = ded.amount + if based_on_payment_days: + amount = self.get_amount_based_on_payment_days(ded, joining_date, relieving_date)[0] + taxable_earnings -= flt(amount) + + return frappe._dict({ + "taxable_earnings": taxable_earnings, + "additional_income": additional_income, + "additional_income_with_full_tax": additional_income_with_full_tax, + "flexi_benefits": flexi_benefits + }) + + def get_amount_based_on_payment_days(self, row, joining_date, relieving_date): + amount, additional_amount = row.amount, row.additional_amount + if (self.salary_structure and + cint(row.depends_on_payment_days) and cint(self.total_working_days) and + (not self.salary_slip_based_on_timesheet or + getdate(self.start_date) < joining_date or + (relieving_date and getdate(self.end_date) > relieving_date) + )): + additional_amount = flt((flt(row.additional_amount) * flt(self.payment_days) + / cint(self.total_working_days)), row.precision("additional_amount")) + amount = flt((flt(row.default_amount) * flt(self.payment_days) + / cint(self.total_working_days)), row.precision("amount")) + additional_amount + + elif not self.payment_days and not self.salary_slip_based_on_timesheet and cint(row.depends_on_payment_days): + amount, additional_amount = 0, 0 + elif not row.amount: + amount = flt(row.default_amount) + flt(row.additional_amount) + + # apply rounding + if frappe.get_cached_value("Salary Component", row.salary_component, "round_to_the_nearest_integer"): + amount, additional_amount = rounded(amount), rounded(additional_amount) + + return amount, additional_amount + + def calculate_unclaimed_taxable_benefits(self, payroll_period): + # get total sum of benefits paid + total_benefits_paid = flt(frappe.db.sql(""" + select sum(sd.amount) + from `tabSalary Detail` sd join `tabSalary Slip` ss on sd.parent=ss.name + where + sd.parentfield='earnings' + and sd.is_tax_applicable=1 + and is_flexible_benefit=1 + and ss.docstatus=1 + and ss.employee=%(employee)s + and ss.start_date between %(start_date)s and %(end_date)s + and ss.end_date between %(start_date)s and %(end_date)s + """, { + "employee": self.employee, + "start_date": payroll_period.start_date, + "end_date": self.start_date + })[0][0]) + + # get total benefits claimed + total_benefits_claimed = flt(frappe.db.sql(""" + select sum(claimed_amount) + from `tabEmployee Benefit Claim` + where + docstatus=1 + and employee=%s + and claim_date between %s and %s + """, (self.employee, payroll_period.start_date, self.end_date))[0][0]) + + return total_benefits_paid - total_benefits_claimed + + def get_total_exemption_amount(self, payroll_period, tax_slab): + total_exemption_amount = 0 + if tax_slab.allow_tax_exemption: + if self.deduct_tax_for_unsubmitted_tax_exemption_proof: + exemption_proof = frappe.db.get_value("Employee Tax Exemption Proof Submission", + {"employee": self.employee, "payroll_period": payroll_period.name, "docstatus": 1}, + ["exemption_amount"]) + if exemption_proof: + total_exemption_amount = exemption_proof + else: + declaration = frappe.db.get_value("Employee Tax Exemption Declaration", + {"employee": self.employee, "payroll_period": payroll_period.name, "docstatus": 1}, + ["total_exemption_amount"]) + if declaration: + total_exemption_amount = declaration + + total_exemption_amount += flt(tax_slab.standard_tax_exemption_amount) + + return total_exemption_amount + + def get_income_form_other_sources(self, payroll_period): + return frappe.get_all("Employee Other Income", + filters={ + "employee": self.employee, + "payroll_period": payroll_period.name, + "company": self.company, + "docstatus": 1 + }, + fields="SUM(amount) as total_amount" + )[0].total_amount + + def calculate_tax_by_tax_slab(self, annual_taxable_earning, tax_slab): + data = self.get_data_for_eval() + data.update({"annual_taxable_earning": annual_taxable_earning}) + tax_amount = 0 + for slab in tax_slab.slabs: + if slab.condition and not self.eval_tax_slab_condition(slab.condition, data): + continue + if not slab.to_amount and annual_taxable_earning >= slab.from_amount: + tax_amount += (annual_taxable_earning - slab.from_amount + 1) * slab.percent_deduction *.01 + continue + if annual_taxable_earning >= slab.from_amount and annual_taxable_earning < slab.to_amount: + tax_amount += (annual_taxable_earning - slab.from_amount + 1) * slab.percent_deduction *.01 + elif annual_taxable_earning >= slab.from_amount and annual_taxable_earning >= slab.to_amount: + tax_amount += (slab.to_amount - slab.from_amount + 1) * slab.percent_deduction * .01 + + # other taxes and charges on income tax + for d in tax_slab.other_taxes_and_charges: + if flt(d.min_taxable_income) and flt(d.min_taxable_income) > annual_taxable_earning: + continue + + if flt(d.max_taxable_income) and flt(d.max_taxable_income) < annual_taxable_earning: + continue + + tax_amount += tax_amount * flt(d.percent) / 100 + + return tax_amount + + def eval_tax_slab_condition(self, condition, data): + try: + condition = condition.strip() + if condition: + return frappe.safe_eval(condition, self.whitelisted_globals, data) + except NameError as err: + frappe.throw(_("{0}
This error can be due to missing or deleted field.").format(err), + title=_("Name error")) + except SyntaxError as err: + frappe.throw(_("Syntax error in condition: {0}").format(err)) + except Exception as e: + frappe.throw(_("Error in formula or condition: {0}").format(e)) + raise + + def get_component_totals(self, component_type, depends_on_payment_days=0): + joining_date, relieving_date = frappe.get_cached_value("Employee", self.employee, + ["date_of_joining", "relieving_date"]) + + total = 0.0 + for d in self.get(component_type): + if not d.do_not_include_in_total: + if depends_on_payment_days: + amount = self.get_amount_based_on_payment_days(d, joining_date, relieving_date)[0] + else: + amount = flt(d.amount, d.precision("amount")) + total += amount + return total + + def set_component_amounts_based_on_payment_days(self): + joining_date, relieving_date = frappe.get_cached_value("Employee", self.employee, + ["date_of_joining", "relieving_date"]) + + if not relieving_date: + relieving_date = getdate(self.end_date) + + if not joining_date: + frappe.throw(_("Please set the Date Of Joining for employee {0}").format(frappe.bold(self.employee_name))) + + for component_type in ("earnings", "deductions"): + for d in self.get(component_type): + d.amount = flt(self.get_amount_based_on_payment_days(d, joining_date, relieving_date)[0], d.precision("amount")) + + def set_loan_repayment(self): + self.total_loan_repayment = 0 + self.total_interest_amount = 0 + self.total_principal_amount = 0 + + if not self.get('loans'): + for loan in self.get_loan_details(): + + amounts = calculate_amounts(loan.name, self.posting_date, "Regular Payment") + + if amounts['interest_amount'] or amounts['payable_principal_amount']: + self.append('loans', { + 'loan': loan.name, + 'total_payment': amounts['interest_amount'] + amounts['payable_principal_amount'], + 'interest_amount': amounts['interest_amount'], + 'principal_amount': amounts['payable_principal_amount'], + 'loan_account': loan.loan_account, + 'interest_income_account': loan.interest_income_account + }) + + for payment in self.get('loans'): + amounts = calculate_amounts(payment.loan, self.posting_date, "Regular Payment") + total_amount = amounts['interest_amount'] + amounts['payable_principal_amount'] + if payment.total_payment > total_amount: + frappe.throw(_("""Row {0}: Paid amount {1} is greater than pending accrued amount {2} against loan {3}""") + .format(payment.idx, frappe.bold(payment.total_payment), + frappe.bold(total_amount), frappe.bold(payment.loan))) + + self.total_interest_amount += payment.interest_amount + self.total_principal_amount += payment.principal_amount + + self.total_loan_repayment += payment.total_payment + + def get_loan_details(self): + return frappe.get_all("Loan", + fields=["name", "interest_income_account", "loan_account", "loan_type"], + filters = { + "applicant": self.employee, + "docstatus": 1, + "repay_from_salary": 1, + "company": self.company + }) + + def make_loan_repayment_entry(self): + for loan in self.loans: + repayment_entry = create_repayment_entry(loan.loan, self.employee, + self.company, self.posting_date, loan.loan_type, "Regular Payment", loan.interest_amount, + loan.principal_amount, loan.total_payment) + + repayment_entry.save() + repayment_entry.submit() + + frappe.db.set_value("Salary Slip Loan", loan.name, "loan_repayment_entry", repayment_entry.name) + + def cancel_loan_repayment_entry(self): + for loan in self.loans: + if loan.loan_repayment_entry: + repayment_entry = frappe.get_doc("Loan Repayment", loan.loan_repayment_entry) + repayment_entry.cancel() + + def email_salary_slip(self): + receiver = frappe.db.get_value("Employee", self.employee, "prefered_email") + payroll_settings = frappe.get_single("Payroll Settings") + message = "Please see attachment" + password = None + if payroll_settings.encrypt_salary_slips_in_emails: + password = generate_password_for_pdf(payroll_settings.password_policy, self.employee) + message += """
Note: Your salary slip is password protected, + the password to unlock the PDF is of the format {0}. """.format(payroll_settings.password_policy) + + if receiver: + email_args = { + "recipients": [receiver], + "message": _(message), + "subject": 'Salary Slip - from {0} to {1}'.format(self.start_date, self.end_date), + "attachments": [frappe.attach_print(self.doctype, self.name, file_name=self.name, password=password)], + "reference_doctype": self.doctype, + "reference_name": self.name + } + if not frappe.flags.in_test: + enqueue(method=frappe.sendmail, queue='short', timeout=300, is_async=True, **email_args) + else: + frappe.sendmail(**email_args) + else: + msgprint(_("{0}: Employee email not found, hence email not sent").format(self.employee_name)) + + def update_status(self, salary_slip=None): + for data in self.timesheets: + if data.time_sheet: + timesheet = frappe.get_doc('Timesheet', data.time_sheet) + timesheet.salary_slip = salary_slip + timesheet.flags.ignore_validate_update_after_submit = True + timesheet.set_status() + timesheet.save() + + def set_status(self, status=None): + '''Get and update status''' + if not status: + status = self.get_status() + self.db_set("status", status) + + + def process_salary_structure(self, for_preview=0): + '''Calculate salary after salary structure details have been updated''' + if not self.salary_slip_based_on_timesheet: + self.get_date_details() + self.pull_emp_details() + self.get_working_days_details(for_preview=for_preview) + self.calculate_net_pay() + + def pull_emp_details(self): + emp = frappe.db.get_value("Employee", self.employee, ["bank_name", "bank_ac_no", "salary_mode"], as_dict=1) + if emp: + self.mode_of_payment = emp.salary_mode + self.bank_name = emp.bank_name + self.bank_account_no = emp.bank_ac_no + + @frappe.whitelist() + def process_salary_based_on_working_days(self): + self.get_working_days_details(lwp=self.leave_without_pay) + self.calculate_net_pay() + + @frappe.whitelist() + def set_totals(self): + self.gross_pay = 0.0 + if self.salary_slip_based_on_timesheet == 1: + self.calculate_total_for_salary_slip_based_on_timesheet() + else: + self.total_deduction = 0.0 + if hasattr(self, "earnings"): + for earning in self.earnings: + self.gross_pay += flt(earning.amount, earning.precision("amount")) + if hasattr(self, "deductions"): + for deduction in self.deductions: + self.total_deduction += flt(deduction.amount, deduction.precision("amount")) + self.net_pay = flt(self.gross_pay) - flt(self.total_deduction) - flt(self.total_loan_repayment) + self.set_base_totals() + + def set_base_totals(self): + self.base_gross_pay = flt(self.gross_pay) * flt(self.exchange_rate) + self.base_total_deduction = flt(self.total_deduction) * flt(self.exchange_rate) + self.rounded_total = rounded(self.net_pay) + self.base_net_pay = flt(self.net_pay) * flt(self.exchange_rate) + self.base_rounded_total = rounded(self.base_net_pay) + self.set_net_total_in_words() + + #calculate total working hours, earnings based on hourly wages and totals + def calculate_total_for_salary_slip_based_on_timesheet(self): + if self.timesheets: + self.total_working_hours = 0 + for timesheet in self.timesheets: + if timesheet.working_hours: + self.total_working_hours += timesheet.working_hours + + wages_amount = self.total_working_hours * self.hour_rate + self.base_hour_rate = flt(self.hour_rate) * flt(self.exchange_rate) + salary_component = frappe.db.get_value('Salary Structure', {'name': self.salary_structure}, 'salary_component') + if self.earnings: + for i, earning in enumerate(self.earnings): + if earning.salary_component == salary_component: + self.earnings[i].amount = wages_amount + self.gross_pay += self.earnings[i].amount + self.net_pay = flt(self.gross_pay) - flt(self.total_deduction) + + def compute_year_to_date(self): + year_to_date = 0 + period_start_date, period_end_date = self.get_year_to_date_period() + + salary_slip_sum = frappe.get_list('Salary Slip', + fields = ['sum(net_pay) as sum'], + filters = {'employee_name' : self.employee_name, + 'start_date' : ['>=', period_start_date], + 'end_date' : ['<', period_end_date], + 'name': ['!=', self.name], + 'docstatus': 1 + }) + + year_to_date = flt(salary_slip_sum[0].sum) if salary_slip_sum else 0.0 + + year_to_date += self.net_pay + self.year_to_date = year_to_date + + def compute_month_to_date(self): + month_to_date = 0 + first_day_of_the_month = get_first_day(self.start_date) + salary_slip_sum = frappe.get_list('Salary Slip', + fields = ['sum(net_pay) as sum'], + filters = {'employee_name' : self.employee_name, + 'start_date' : ['>=', first_day_of_the_month], + 'end_date' : ['<', self.start_date], + 'name': ['!=', self.name], + 'docstatus': 1 + }) + + month_to_date = flt(salary_slip_sum[0].sum) if salary_slip_sum else 0.0 + + month_to_date += self.net_pay + self.month_to_date = month_to_date + + def compute_component_wise_year_to_date(self): + period_start_date, period_end_date = self.get_year_to_date_period() + + for key in ('earnings', 'deductions'): + for component in self.get(key): + year_to_date = 0 + component_sum = frappe.db.sql(""" + SELECT sum(detail.amount) as sum + FROM `tabSalary Detail` as detail + INNER JOIN `tabSalary Slip` as salary_slip + ON detail.parent = salary_slip.name + WHERE + salary_slip.employee_name = %(employee_name)s + AND detail.salary_component = %(component)s + AND salary_slip.start_date >= %(period_start_date)s + AND salary_slip.end_date < %(period_end_date)s + AND salary_slip.name != %(docname)s + AND salary_slip.docstatus = 1""", + {'employee_name': self.employee_name, 'component': component.salary_component, 'period_start_date': period_start_date, + 'period_end_date': period_end_date, 'docname': self.name} + ) + + year_to_date = flt(component_sum[0][0]) if component_sum else 0.0 + year_to_date += component.amount + component.year_to_date = year_to_date + + def get_year_to_date_period(self): + payroll_period = get_payroll_period(self.start_date, self.end_date, self.company) + + if payroll_period: + period_start_date = payroll_period.start_date + period_end_date = payroll_period.end_date + else: + # get dates based on fiscal year if no payroll period exists + fiscal_year = get_fiscal_year(date=self.start_date, company=self.company, as_dict=1) + period_start_date = fiscal_year.year_start_date + period_end_date = fiscal_year.year_end_date + + return period_start_date, period_end_date + + def add_leave_balances(self): + self.set('leave_details', []) + + if frappe.db.get_single_value('Payroll Settings', 'show_leave_balances_in_salary_slip'): + from erpnext.hr.doctype.leave_application.leave_application import get_leave_details + leave_details = get_leave_details(self.employee, self.end_date) + + for leave_type, leave_values in iteritems(leave_details['leave_allocation']): + self.append('leave_details', { + 'leave_type': leave_type, + 'total_allocated_leaves': flt(leave_values.get('total_leaves')), + 'expired_leaves': flt(leave_values.get('expired_leaves')), + 'used_leaves': flt(leave_values.get('leaves_taken')), + 'pending_leaves': flt(leave_values.get('pending_leaves')), + 'available_leaves': flt(leave_values.get('remaining_leaves')) + }) + +def unlink_ref_doc_from_salary_slip(ref_no): + linked_ss = frappe.db.sql_list("""select name from `tabSalary Slip` + where journal_entry=%s and docstatus < 2""", (ref_no)) + if linked_ss: + for ss in linked_ss: + ss_doc = frappe.get_doc("Salary Slip", ss) + frappe.db.set_value("Salary Slip", ss_doc.name, "journal_entry", "") + +def generate_password_for_pdf(policy_template, employee): + employee = frappe.get_doc("Employee", employee) + return policy_template.format(**employee.as_dict()) + +def get_salary_component_data(component): + return frappe.get_value( + "Salary Component", + component, + [ + "name as salary_component", + "depends_on_payment_days", + "salary_component_abbr as abbr", + "do_not_include_in_total", + "is_tax_applicable", + "is_flexible_benefit", + "variable_based_on_taxable_salary", + ], + as_dict=1, + ) diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index 0da2dbb71c9..a28d3c000e1 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -74,9 +74,22 @@ $.extend(erpnext, { }); }, +<<<<<<< HEAD route_to_pending_reposts: (args) => { frappe.set_route("List", "Repost Item Valuation", args); }, +======= + proceed_save_with_reminders_frequency_change: () => { + frappe.ui.hide_open_dialog(); + + frappe.call({ + method: 'erpnext.hr.doctype.hr_settings.hr_settings.set_proceed_with_frequency_change', + callback: () => { + cur_frm.save(); + } + }); + } +>>>>>>> 24b2a31581 (feat: Employee reminders (#25735)) }); $.extend(erpnext.utils, { diff --git a/erpnext/setup/doctype/employee/employee_reminders.py b/erpnext/setup/doctype/employee/employee_reminders.py new file mode 100644 index 00000000000..2155c027a9b --- /dev/null +++ b/erpnext/setup/doctype/employee/employee_reminders.py @@ -0,0 +1,247 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +from frappe import _ +from frappe.utils import comma_sep, getdate, today, add_months, add_days +from erpnext.hr.doctype.employee.employee import get_all_employee_emails, get_employee_email +from erpnext.hr.utils import get_holidays_for_employee + +# ----------------- +# HOLIDAY REMINDERS +# ----------------- +def send_reminders_in_advance_weekly(): + to_send_in_advance = int(frappe.db.get_single_value("HR Settings", "send_holiday_reminders") or 1) + frequency = frappe.db.get_single_value("HR Settings", "frequency") + if not (to_send_in_advance and frequency == "Weekly"): + return + + send_advance_holiday_reminders("Weekly") + +def send_reminders_in_advance_monthly(): + to_send_in_advance = int(frappe.db.get_single_value("HR Settings", "send_holiday_reminders") or 1) + frequency = frappe.db.get_single_value("HR Settings", "frequency") + if not (to_send_in_advance and frequency == "Monthly"): + return + + send_advance_holiday_reminders("Monthly") + +def send_advance_holiday_reminders(frequency): + """Send Holiday Reminders in Advance to Employees + `frequency` (str): 'Weekly' or 'Monthly' + """ + if frequency == "Weekly": + start_date = getdate() + end_date = add_days(getdate(), 7) + elif frequency == "Monthly": + # Sent on 1st of every month + start_date = getdate() + end_date = add_months(getdate(), 1) + else: + return + + employees = frappe.db.get_all('Employee', pluck='name') + for employee in employees: + holidays = get_holidays_for_employee( + employee, + start_date, end_date, + only_non_weekly=True, + raise_exception=False + ) + + if not (holidays is None): + send_holidays_reminder_in_advance(employee, holidays) + +def send_holidays_reminder_in_advance(employee, holidays): + employee_doc = frappe.get_doc('Employee', employee) + employee_email = get_employee_email(employee_doc) + frequency = frappe.db.get_single_value("HR Settings", "frequency") + + email_header = _("Holidays this Month.") if frequency == "Monthly" else _("Holidays this Week.") + frappe.sendmail( + recipients=[employee_email], + subject=_("Upcoming Holidays Reminder"), + template="holiday_reminder", + args=dict( + reminder_text=_("Hey {}! This email is to remind you about the upcoming holidays.").format(employee_doc.get('first_name')), + message=_("Below is the list of upcoming holidays for you:"), + advance_holiday_reminder=True, + holidays=holidays, + frequency=frequency[:-2] + ), + header=email_header + ) + +# ------------------ +# BIRTHDAY REMINDERS +# ------------------ +def send_birthday_reminders(): + """Send Employee birthday reminders if no 'Stop Birthday Reminders' is not set.""" + to_send = int(frappe.db.get_single_value("HR Settings", "send_birthday_reminders") or 1) + if not to_send: + return + + employees_born_today = get_employees_who_are_born_today() + + for company, birthday_persons in employees_born_today.items(): + employee_emails = get_all_employee_emails(company) + birthday_person_emails = [get_employee_email(doc) for doc in birthday_persons] + recipients = list(set(employee_emails) - set(birthday_person_emails)) + + reminder_text, message = get_birthday_reminder_text_and_message(birthday_persons) + send_birthday_reminder(recipients, reminder_text, birthday_persons, message) + + if len(birthday_persons) > 1: + # special email for people sharing birthdays + for person in birthday_persons: + person_email = person["user_id"] or person["personal_email"] or person["company_email"] + others = [d for d in birthday_persons if d != person] + reminder_text, message = get_birthday_reminder_text_and_message(others) + send_birthday_reminder(person_email, reminder_text, others, message) + +def get_birthday_reminder_text_and_message(birthday_persons): + if len(birthday_persons) == 1: + birthday_person_text = birthday_persons[0]['name'] + else: + # converts ["Jim", "Rim", "Dim"] to Jim, Rim & Dim + person_names = [d['name'] for d in birthday_persons] + birthday_person_text = comma_sep(person_names, frappe._("{0} & {1}"), False) + + reminder_text = _("Today is {0}'s birthday πŸŽ‰").format(birthday_person_text) + message = _("A friendly reminder of an important date for our team.") + message += "
" + message += _("Everyone, let’s congratulate {0} on their birthday.").format(birthday_person_text) + + return reminder_text, message + +def send_birthday_reminder(recipients, reminder_text, birthday_persons, message): + frappe.sendmail( + recipients=recipients, + subject=_("Birthday Reminder"), + template="birthday_reminder", + args=dict( + reminder_text=reminder_text, + birthday_persons=birthday_persons, + message=message, + ), + header=_("Birthday Reminder πŸŽ‚") + ) + +def get_employees_who_are_born_today(): + """Get all employee born today & group them based on their company""" + return get_employees_having_an_event_today("birthday") + +def get_employees_having_an_event_today(event_type): + """Get all employee who have `event_type` today + & group them based on their company. `event_type` + can be `birthday` or `work_anniversary`""" + + from collections import defaultdict + + # Set column based on event type + if event_type == 'birthday': + condition_column = 'date_of_birth' + elif event_type == 'work_anniversary': + condition_column = 'date_of_joining' + else: + return + + employees_born_today = frappe.db.multisql({ + "mariadb": f""" + SELECT `personal_email`, `company`, `company_email`, `user_id`, `employee_name` AS 'name', `image`, `date_of_joining` + FROM `tabEmployee` + WHERE + DAY({condition_column}) = DAY(%(today)s) + AND + MONTH({condition_column}) = MONTH(%(today)s) + AND + `status` = 'Active' + """, + "postgres": f""" + SELECT "personal_email", "company", "company_email", "user_id", "employee_name" AS 'name', "image" + FROM "tabEmployee" + WHERE + DATE_PART('day', {condition_column}) = date_part('day', %(today)s) + AND + DATE_PART('month', {condition_column}) = date_part('month', %(today)s) + AND + "status" = 'Active' + """, + }, dict(today=today(), condition_column=condition_column), as_dict=1) + + grouped_employees = defaultdict(lambda: []) + + for employee_doc in employees_born_today: + grouped_employees[employee_doc.get('company')].append(employee_doc) + + return grouped_employees + + +# -------------------------- +# WORK ANNIVERSARY REMINDERS +# -------------------------- +def send_work_anniversary_reminders(): + """Send Employee Work Anniversary Reminders if 'Send Work Anniversary Reminders' is checked""" + to_send = int(frappe.db.get_single_value("HR Settings", "send_work_anniversary_reminders") or 1) + if not to_send: + return + + employees_joined_today = get_employees_having_an_event_today("work_anniversary") + + for company, anniversary_persons in employees_joined_today.items(): + employee_emails = get_all_employee_emails(company) + anniversary_person_emails = [get_employee_email(doc) for doc in anniversary_persons] + recipients = list(set(employee_emails) - set(anniversary_person_emails)) + + reminder_text, message = get_work_anniversary_reminder_text_and_message(anniversary_persons) + send_work_anniversary_reminder(recipients, reminder_text, anniversary_persons, message) + + if len(anniversary_persons) > 1: + # email for people sharing work anniversaries + for person in anniversary_persons: + person_email = person["user_id"] or person["personal_email"] or person["company_email"] + others = [d for d in anniversary_persons if d != person] + reminder_text, message = get_work_anniversary_reminder_text_and_message(others) + send_work_anniversary_reminder(person_email, reminder_text, others, message) + +def get_work_anniversary_reminder_text_and_message(anniversary_persons): + if len(anniversary_persons) == 1: + anniversary_person = anniversary_persons[0]['name'] + persons_name = anniversary_person + # Number of years completed at the company + completed_years = getdate().year - anniversary_persons[0]['date_of_joining'].year + anniversary_person += f" completed {completed_years} years" + else: + person_names_with_years = [] + names = [] + for person in anniversary_persons: + person_text = person['name'] + names.append(person_text) + # Number of years completed at the company + completed_years = getdate().year - person['date_of_joining'].year + person_text += f" completed {completed_years} years" + person_names_with_years.append(person_text) + + # converts ["Jim", "Rim", "Dim"] to Jim, Rim & Dim + anniversary_person = comma_sep(person_names_with_years, frappe._("{0} & {1}"), False) + persons_name = comma_sep(names, frappe._("{0} & {1}"), False) + + reminder_text = _("Today {0} at our Company! πŸŽ‰").format(anniversary_person) + message = _("A friendly reminder of an important date for our team.") + message += "
" + message += _("Everyone, let’s congratulate {0} on their work anniversary!").format(persons_name) + + return reminder_text, message + +def send_work_anniversary_reminder(recipients, reminder_text, anniversary_persons, message): + frappe.sendmail( + recipients=recipients, + subject=_("Work Anniversary Reminder"), + template="anniversary_reminder", + args=dict( + reminder_text=reminder_text, + anniversary_persons=anniversary_persons, + message=message, + ), + header=_("🎊️🎊️ Work Anniversary Reminder 🎊️🎊️") + ) diff --git a/erpnext/setup/doctype/employee/test_employee_reminders.py b/erpnext/setup/doctype/employee/test_employee_reminders.py new file mode 100644 index 00000000000..7e560f512d1 --- /dev/null +++ b/erpnext/setup/doctype/employee/test_employee_reminders.py @@ -0,0 +1,173 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# License: GNU General Public License v3. See license.txt + +import frappe +import unittest + +from frappe.utils import getdate +from datetime import timedelta +from erpnext.hr.doctype.employee.test_employee import make_employee +from erpnext.hr.doctype.hr_settings.hr_settings import set_proceed_with_frequency_change + + +class TestEmployeeReminders(unittest.TestCase): + @classmethod + def setUpClass(cls): + from erpnext.hr.doctype.holiday_list.test_holiday_list import make_holiday_list + + # Create a test holiday list + test_holiday_dates = cls.get_test_holiday_dates() + test_holiday_list = make_holiday_list( + 'TestHolidayRemindersList', + holiday_dates=[ + {'holiday_date': test_holiday_dates[0], 'description': 'test holiday1'}, + {'holiday_date': test_holiday_dates[1], 'description': 'test holiday2'}, + {'holiday_date': test_holiday_dates[2], 'description': 'test holiday3', 'weekly_off': 1}, + {'holiday_date': test_holiday_dates[3], 'description': 'test holiday4'}, + {'holiday_date': test_holiday_dates[4], 'description': 'test holiday5'}, + {'holiday_date': test_holiday_dates[5], 'description': 'test holiday6'}, + ], + from_date=getdate()-timedelta(days=10), + to_date=getdate()+timedelta(weeks=5) + ) + + # Create a test employee + test_employee = frappe.get_doc( + 'Employee', + make_employee('test@gopher.io', company="_Test Company") + ) + + # Attach the holiday list to employee + test_employee.holiday_list = test_holiday_list.name + test_employee.save() + + # Attach to class + cls.test_employee = test_employee + cls.test_holiday_dates = test_holiday_dates + + @classmethod + def get_test_holiday_dates(cls): + today_date = getdate() + return [ + today_date, + today_date-timedelta(days=4), + today_date-timedelta(days=3), + today_date+timedelta(days=1), + today_date+timedelta(days=3), + today_date+timedelta(weeks=3) + ] + + def setUp(self): + # Clear Email Queue + frappe.db.sql("delete from `tabEmail Queue`") + + def test_is_holiday(self): + from erpnext.hr.doctype.employee.employee import is_holiday + + self.assertTrue(is_holiday(self.test_employee.name)) + self.assertTrue(is_holiday(self.test_employee.name, date=self.test_holiday_dates[1])) + self.assertFalse(is_holiday(self.test_employee.name, date=getdate()-timedelta(days=1))) + + # Test weekly_off holidays + self.assertTrue(is_holiday(self.test_employee.name, date=self.test_holiday_dates[2])) + self.assertFalse(is_holiday(self.test_employee.name, date=self.test_holiday_dates[2], only_non_weekly=True)) + + # Test with descriptions + has_holiday, descriptions = is_holiday(self.test_employee.name, with_description=True) + self.assertTrue(has_holiday) + self.assertTrue('test holiday1' in descriptions) + + def test_birthday_reminders(self): + employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0]) + employee.date_of_birth = "1992" + frappe.utils.nowdate()[4:] + employee.company_email = "test@example.com" + employee.company = "_Test Company" + employee.save() + + from erpnext.hr.doctype.employee.employee_reminders import get_employees_who_are_born_today, send_birthday_reminders + + employees_born_today = get_employees_who_are_born_today() + self.assertTrue(employees_born_today.get("_Test Company")) + + hr_settings = frappe.get_doc("HR Settings", "HR Settings") + hr_settings.send_birthday_reminders = 1 + hr_settings.save() + + send_birthday_reminders() + + email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True) + self.assertTrue("Subject: Birthday Reminder" in email_queue[0].message) + + def test_work_anniversary_reminders(self): + employee = frappe.get_doc("Employee", frappe.db.sql_list("select name from tabEmployee limit 1")[0]) + employee.date_of_joining = "1998" + frappe.utils.nowdate()[4:] + employee.company_email = "test@example.com" + employee.company = "_Test Company" + employee.save() + + from erpnext.hr.doctype.employee.employee_reminders import get_employees_having_an_event_today, send_work_anniversary_reminders + + employees_having_work_anniversary = get_employees_having_an_event_today('work_anniversary') + self.assertTrue(employees_having_work_anniversary.get("_Test Company")) + + hr_settings = frappe.get_doc("HR Settings", "HR Settings") + hr_settings.send_work_anniversary_reminders = 1 + hr_settings.save() + + send_work_anniversary_reminders() + + email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True) + self.assertTrue("Subject: Work Anniversary Reminder" in email_queue[0].message) + + def test_send_holidays_reminder_in_advance(self): + from erpnext.hr.utils import get_holidays_for_employee + from erpnext.hr.doctype.employee.employee_reminders import send_holidays_reminder_in_advance + + # Get HR settings and enable advance holiday reminders + hr_settings = frappe.get_doc("HR Settings", "HR Settings") + hr_settings.send_holiday_reminders = 1 + set_proceed_with_frequency_change() + hr_settings.frequency = 'Weekly' + hr_settings.save() + + holidays = get_holidays_for_employee( + self.test_employee.get('name'), + getdate(), getdate() + timedelta(days=3), + only_non_weekly=True, + raise_exception=False + ) + + send_holidays_reminder_in_advance( + self.test_employee.get('name'), + holidays + ) + + email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True) + self.assertEqual(len(email_queue), 1) + + def test_advance_holiday_reminders_monthly(self): + from erpnext.hr.doctype.employee.employee_reminders import send_reminders_in_advance_monthly + # Get HR settings and enable advance holiday reminders + hr_settings = frappe.get_doc("HR Settings", "HR Settings") + hr_settings.send_holiday_reminders = 1 + set_proceed_with_frequency_change() + hr_settings.frequency = 'Monthly' + hr_settings.save() + + send_reminders_in_advance_monthly() + + email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True) + self.assertTrue(len(email_queue) > 0) + + def test_advance_holiday_reminders_weekly(self): + from erpnext.hr.doctype.employee.employee_reminders import send_reminders_in_advance_weekly + # Get HR settings and enable advance holiday reminders + hr_settings = frappe.get_doc("HR Settings", "HR Settings") + hr_settings.send_holiday_reminders = 1 + hr_settings.frequency = 'Weekly' + hr_settings.save() + + send_reminders_in_advance_weekly() + + email_queue = frappe.db.sql("""select * from `tabEmail Queue`""", as_dict=True) + self.assertTrue(len(email_queue) > 0) diff --git a/erpnext/templates/emails/anniversary_reminder.html b/erpnext/templates/emails/anniversary_reminder.html new file mode 100644 index 00000000000..ac9f7e4993a --- /dev/null +++ b/erpnext/templates/emails/anniversary_reminder.html @@ -0,0 +1,25 @@ +
+
+ {% for person in anniversary_persons %} + {% if person.image %} + + + {% else %} + + {{ frappe.utils.get_abbr(person.name) }} + + {% endif %} + {% endfor %} +
+
+ {{ reminder_text }} +

{{ message }}

+
+
\ No newline at end of file diff --git a/erpnext/templates/emails/holiday_reminder.html b/erpnext/templates/emails/holiday_reminder.html new file mode 100644 index 00000000000..e38d27bf8bc --- /dev/null +++ b/erpnext/templates/emails/holiday_reminder.html @@ -0,0 +1,16 @@ +
+ {{ reminder_text }} +

{{ message }}

+
+ +{% if advance_holiday_reminder %} + {% if holidays | len > 0 %} +
    + {% for holiday in holidays %} +
  1. {{ frappe.format(holiday.holiday_date, 'Date') }} - {{ holiday.description }}
  2. + {% endfor %} +
+ {% else %} +

You don't have no upcoming holidays this {{ frequency }}.

+ {% endif %} +{% endif %}