Merge pull request #25209 from NagariaHussain/employee-util-report
feat: Employee Hours Utilization Report
diff --git a/erpnext/hr/doctype/hr_settings/hr_settings.json b/erpnext/hr/doctype/hr_settings/hr_settings.json
index 3553229..3db6c23 100644
--- a/erpnext/hr/doctype/hr_settings/hr_settings.json
+++ b/erpnext/hr/doctype/hr_settings/hr_settings.json
@@ -146,7 +146,6 @@
"label": "Send Leave Notification"
},
{
- "default": "8",
"fieldname": "standard_working_hours",
"fieldtype": "Int",
"label": "Standard Working Hours"
@@ -156,7 +155,7 @@
"idx": 1,
"issingle": 1,
"links": [],
- "modified": "2021-04-16 15:45:18.467699",
+ "modified": "2021-04-26 10:52:56.192773",
"modified_by": "Administrator",
"module": "HR",
"name": "HR Settings",
diff --git a/erpnext/projects/report/employee_hours_utilization_based_on_timesheet/__init__.py b/erpnext/projects/report/employee_hours_utilization_based_on_timesheet/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/erpnext/projects/report/employee_hours_utilization_based_on_timesheet/__init__.py
diff --git a/erpnext/projects/report/employee_hours_utilization_based_on_timesheet/employee_hours_utilization_based_on_timesheet.js b/erpnext/projects/report/employee_hours_utilization_based_on_timesheet/employee_hours_utilization_based_on_timesheet.js
new file mode 100644
index 0000000..9a30b99
--- /dev/null
+++ b/erpnext/projects/report/employee_hours_utilization_based_on_timesheet/employee_hours_utilization_based_on_timesheet.js
@@ -0,0 +1,48 @@
+// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors
+// For license information, please see license.txt
+/* eslint-disable */
+
+frappe.query_reports["Employee Hours Utilization Based On Timesheet"] = {
+ "filters": [
+ {
+ fieldname: "company",
+ label: __("Company"),
+ fieldtype: "Link",
+ options: "Company",
+ default: frappe.defaults.get_user_default("Company"),
+ reqd: 1
+ },
+ {
+ fieldname: "from_date",
+ label: __("From Date"),
+ fieldtype: "Date",
+ default: frappe.datetime.add_months(frappe.datetime.get_today(), -1),
+ reqd: 1
+ },
+ {
+ fieldname:"to_date",
+ label: __("To Date"),
+ fieldtype: "Date",
+ default: frappe.datetime.now_date(),
+ reqd: 1
+ },
+ {
+ fieldname: "employee",
+ label: __("Employee"),
+ fieldtype: "Link",
+ options: "Employee"
+ },
+ {
+ fieldname: "department",
+ label: __("Department"),
+ fieldtype: "Link",
+ options: "Department"
+ },
+ {
+ fieldname: "project",
+ label: __("Project"),
+ fieldtype: "Link",
+ options: "Project"
+ }
+ ]
+};
diff --git a/erpnext/projects/report/employee_hours_utilization_based_on_timesheet/employee_hours_utilization_based_on_timesheet.json b/erpnext/projects/report/employee_hours_utilization_based_on_timesheet/employee_hours_utilization_based_on_timesheet.json
new file mode 100644
index 0000000..5ff8186
--- /dev/null
+++ b/erpnext/projects/report/employee_hours_utilization_based_on_timesheet/employee_hours_utilization_based_on_timesheet.json
@@ -0,0 +1,22 @@
+{
+ "add_total_row": 0,
+ "columns": [],
+ "creation": "2021-04-05 19:23:43.838623",
+ "disable_prepared_report": 0,
+ "disabled": 0,
+ "docstatus": 0,
+ "doctype": "Report",
+ "filters": [],
+ "idx": 0,
+ "is_standard": "Yes",
+ "modified": "2021-04-05 19:23:43.838623",
+ "modified_by": "Administrator",
+ "module": "Projects",
+ "name": "Employee Hours Utilization Based On Timesheet",
+ "owner": "Administrator",
+ "prepared_report": 0,
+ "ref_doctype": "Timesheet",
+ "report_name": "Employee Hours Utilization Based On Timesheet",
+ "report_type": "Script Report",
+ "roles": []
+}
\ No newline at end of file
diff --git a/erpnext/projects/report/employee_hours_utilization_based_on_timesheet/employee_hours_utilization_based_on_timesheet.py b/erpnext/projects/report/employee_hours_utilization_based_on_timesheet/employee_hours_utilization_based_on_timesheet.py
new file mode 100644
index 0000000..842fd4d
--- /dev/null
+++ b/erpnext/projects/report/employee_hours_utilization_based_on_timesheet/employee_hours_utilization_based_on_timesheet.py
@@ -0,0 +1,280 @@
+# Copyright (c) 2013, 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 flt, getdate
+from six import iteritems
+
+def execute(filters=None):
+ return EmployeeHoursReport(filters).run()
+
+class EmployeeHoursReport:
+ '''Employee Hours Utilization Report Based On Timesheet'''
+ def __init__(self, filters=None):
+ self.filters = frappe._dict(filters or {})
+
+ self.from_date = getdate(self.filters.from_date)
+ self.to_date = getdate(self.filters.to_date)
+
+ self.validate_dates()
+ self.validate_standard_working_hours()
+
+ def validate_dates(self):
+ self.day_span = (self.to_date - self.from_date).days
+
+ if self.day_span <= 0:
+ frappe.throw(_('From Date must come before To Date'))
+
+ def validate_standard_working_hours(self):
+ self.standard_working_hours = frappe.db.get_single_value('HR Settings', 'standard_working_hours')
+ if not self.standard_working_hours:
+ msg = _('The metrics for this report are calculated based on the Standard Working Hours. Please set {0} in {1}.').format(
+ frappe.bold('Standard Working Hours'), frappe.utils.get_link_to_form('HR Settings', 'HR Settings'))
+
+ frappe.throw(msg)
+
+ def run(self):
+ self.generate_columns()
+ self.generate_data()
+ self.generate_report_summary()
+ self.generate_chart_data()
+
+ return self.columns, self.data, None, self.chart, self.report_summary
+
+ def generate_columns(self):
+ self.columns = [
+ {
+ 'label': _('Employee'),
+ 'options': 'Employee',
+ 'fieldname': 'employee',
+ 'fieldtype': 'Link',
+ 'width': 230
+ },
+ {
+ 'label': _('Department'),
+ 'options': 'Department',
+ 'fieldname': 'department',
+ 'fieldtype': 'Link',
+ 'width': 120
+ },
+ {
+ 'label': _('Total Hours (T)'),
+ 'fieldname': 'total_hours',
+ 'fieldtype': 'Float',
+ 'width': 120
+ },
+ {
+ 'label': _('Billed Hours (B)'),
+ 'fieldname': 'billed_hours',
+ 'fieldtype': 'Float',
+ 'width': 170
+ },
+ {
+ 'label': _('Non-Billed Hours (NB)'),
+ 'fieldname': 'non_billed_hours',
+ 'fieldtype': 'Float',
+ 'width': 170
+ },
+ {
+ 'label': _('Untracked Hours (U)'),
+ 'fieldname': 'untracked_hours',
+ 'fieldtype': 'Float',
+ 'width': 170
+ },
+ {
+ 'label': _('% Utilization (B + NB) / T'),
+ 'fieldname': 'per_util',
+ 'fieldtype': 'Percentage',
+ 'width': 200
+ },
+ {
+ 'label': _('% Utilization (B / T)'),
+ 'fieldname': 'per_util_billed_only',
+ 'fieldtype': 'Percentage',
+ 'width': 200
+ }
+ ]
+
+ def generate_data(self):
+ self.generate_filtered_time_logs()
+ self.generate_stats_by_employee()
+ self.set_employee_department_and_name()
+
+ if self.filters.department:
+ self.filter_stats_by_department()
+
+ self.calculate_utilizations()
+
+ self.data = []
+
+ for emp, data in iteritems(self.stats_by_employee):
+ row = frappe._dict()
+ row['employee'] = emp
+ row.update(data)
+ self.data.append(row)
+
+ # Sort by descending order of percentage utilization
+ self.data.sort(key=lambda x: x['per_util'], reverse=True)
+
+ def filter_stats_by_department(self):
+ filtered_data = frappe._dict()
+ for emp, data in self.stats_by_employee.items():
+ if data['department'] == self.filters.department:
+ filtered_data[emp] = data
+
+ # Update stats
+ self.stats_by_employee = filtered_data
+
+ def generate_filtered_time_logs(self):
+ additional_filters = ''
+
+ filter_fields = ['employee', 'project', 'company']
+
+ for field in filter_fields:
+ if self.filters.get(field):
+ if field == 'project':
+ additional_filters += f"AND ttd.{field} = '{self.filters.get(field)}'"
+ else:
+ additional_filters += f"AND tt.{field} = '{self.filters.get(field)}'"
+
+ self.filtered_time_logs = frappe.db.sql('''
+ SELECT tt.employee AS employee, ttd.hours AS hours, ttd.billable AS billable, ttd.project AS project
+ FROM `tabTimesheet Detail` AS ttd
+ JOIN `tabTimesheet` AS tt
+ ON ttd.parent = tt.name
+ WHERE tt.employee IS NOT NULL
+ AND tt.start_date BETWEEN '{0}' AND '{1}'
+ AND tt.end_date BETWEEN '{0}' AND '{1}'
+ {2}
+ '''.format(self.filters.from_date, self.filters.to_date, additional_filters))
+
+ def generate_stats_by_employee(self):
+ self.stats_by_employee = frappe._dict()
+
+ for emp, hours, billable, project in self.filtered_time_logs:
+ self.stats_by_employee.setdefault(
+ emp, frappe._dict()
+ ).setdefault('billed_hours', 0.0)
+
+ self.stats_by_employee[emp].setdefault('non_billed_hours', 0.0)
+
+ if billable:
+ self.stats_by_employee[emp]['billed_hours'] += flt(hours, 2)
+ else:
+ self.stats_by_employee[emp]['non_billed_hours'] += flt(hours, 2)
+
+ def set_employee_department_and_name(self):
+ for emp in self.stats_by_employee:
+ emp_name = frappe.db.get_value(
+ 'Employee', emp, 'employee_name'
+ )
+ emp_dept = frappe.db.get_value(
+ 'Employee', emp, 'department'
+ )
+
+ self.stats_by_employee[emp]['department'] = emp_dept
+ self.stats_by_employee[emp]['employee_name'] = emp_name
+
+ def calculate_utilizations(self):
+ TOTAL_HOURS = flt(self.standard_working_hours * self.day_span, 2)
+ for emp, data in iteritems(self.stats_by_employee):
+ data['total_hours'] = TOTAL_HOURS
+ data['untracked_hours'] = flt(TOTAL_HOURS - data['billed_hours'] - data['non_billed_hours'], 2)
+
+ # To handle overtime edge-case
+ if data['untracked_hours'] < 0:
+ data['untracked_hours'] = 0.0
+
+ data['per_util'] = flt(((data['billed_hours'] + data['non_billed_hours']) / TOTAL_HOURS) * 100, 2)
+ data['per_util_billed_only'] = flt((data['billed_hours'] / TOTAL_HOURS) * 100, 2)
+
+ def generate_report_summary(self):
+ self.report_summary = []
+
+ if not self.data:
+ return
+
+ avg_utilization = 0.0
+ avg_utilization_billed_only = 0.0
+ total_billed, total_non_billed = 0.0, 0.0
+ total_untracked = 0.0
+
+ for row in self.data:
+ avg_utilization += row['per_util']
+ avg_utilization_billed_only += row['per_util_billed_only']
+ total_billed += row['billed_hours']
+ total_non_billed += row['non_billed_hours']
+ total_untracked += row['untracked_hours']
+
+ avg_utilization /= len(self.data)
+ avg_utilization = flt(avg_utilization, 2)
+
+ avg_utilization_billed_only /= len(self.data)
+ avg_utilization_billed_only = flt(avg_utilization_billed_only, 2)
+
+ THRESHOLD_PERCENTAGE = 70.0
+ self.report_summary = [
+ {
+ 'value': f'{avg_utilization}%',
+ 'indicator': 'Red' if avg_utilization < THRESHOLD_PERCENTAGE else 'Green',
+ 'label': _('Avg Utilization'),
+ 'datatype': 'Percentage'
+ },
+ {
+ 'value': f'{avg_utilization_billed_only}%',
+ 'indicator': 'Red' if avg_utilization_billed_only < THRESHOLD_PERCENTAGE else 'Green',
+ 'label': _('Avg Utilization (Billed Only)'),
+ 'datatype': 'Percentage'
+ },
+ {
+ 'value': total_billed,
+ 'label': _('Total Billed Hours'),
+ 'datatype': 'Float'
+ },
+ {
+ 'value': total_non_billed,
+ 'label': _('Total Non-Billed Hours'),
+ 'datatype': 'Float'
+ }
+ ]
+
+ def generate_chart_data(self):
+ self.chart = {}
+
+ labels = []
+ billed_hours = []
+ non_billed_hours = []
+ untracked_hours = []
+
+
+ for row in self.data:
+ labels.append(row.get('employee_name'))
+ billed_hours.append(row.get('billed_hours'))
+ non_billed_hours.append(row.get('non_billed_hours'))
+ untracked_hours.append(row.get('untracked_hours'))
+
+ self.chart = {
+ 'data': {
+ 'labels': labels[:30],
+ 'datasets': [
+ {
+ 'name': _('Billed Hours'),
+ 'values': billed_hours[:30]
+ },
+ {
+ 'name': _('Non-Billed Hours'),
+ 'values': non_billed_hours[:30]
+ },
+ {
+ 'name': _('Untracked Hours'),
+ 'values': untracked_hours[:30]
+ }
+ ]
+ },
+ 'type': 'bar',
+ 'barOptions': {
+ 'stacked': True
+ }
+ }
diff --git a/erpnext/projects/report/employee_hours_utilization_based_on_timesheet/test_employee_util.py b/erpnext/projects/report/employee_hours_utilization_based_on_timesheet/test_employee_util.py
new file mode 100644
index 0000000..fa87827
--- /dev/null
+++ b/erpnext/projects/report/employee_hours_utilization_based_on_timesheet/test_employee_util.py
@@ -0,0 +1,198 @@
+from __future__ import unicode_literals
+import unittest
+import frappe
+
+from frappe.utils.make_random import get_random
+from erpnext.projects.report.employee_hours_utilization_based_on_timesheet.employee_hours_utilization_based_on_timesheet import execute
+from erpnext.hr.doctype.employee.test_employee import make_employee
+from erpnext.projects.doctype.project.test_project import make_project
+
+class TestEmployeeUtilization(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ # Create test employee
+ cls.test_emp1 = make_employee("test1@employeeutil.com", "_Test Company")
+ cls.test_emp2 = make_employee("test2@employeeutil.com", "_Test Company")
+
+ # Create test project
+ cls.test_project = make_project({"project_name": "_Test Project"})
+
+ # Create test timesheets
+ cls.create_test_timesheets()
+
+ frappe.db.set_value("HR Settings", "HR Settings", "standard_working_hours", 9)
+
+ @classmethod
+ def create_test_timesheets(cls):
+ timesheet1 = frappe.new_doc("Timesheet")
+ timesheet1.employee = cls.test_emp1
+ timesheet1.company = '_Test Company'
+
+ timesheet1.append("time_logs", {
+ "activity_type": get_random("Activity Type"),
+ "hours": 5,
+ "billable": 1,
+ "from_time": '2021-04-01 13:30:00.000000',
+ "to_time": '2021-04-01 18:30:00.000000'
+ })
+
+ timesheet1.save()
+ timesheet1.submit()
+
+ timesheet2 = frappe.new_doc("Timesheet")
+ timesheet2.employee = cls.test_emp2
+ timesheet2.company = '_Test Company'
+
+ timesheet2.append("time_logs", {
+ "activity_type": get_random("Activity Type"),
+ "hours": 10,
+ "billable": 0,
+ "from_time": '2021-04-01 13:30:00.000000',
+ "to_time": '2021-04-01 23:30:00.000000',
+ "project": cls.test_project.name
+ })
+
+ timesheet2.save()
+ timesheet2.submit()
+
+ @classmethod
+ def tearDownClass(cls):
+ # Delete time logs
+ frappe.db.sql("""
+ DELETE FROM `tabTimesheet Detail`
+ WHERE parent IN (
+ SELECT name
+ FROM `tabTimesheet`
+ WHERE company = '_Test Company'
+ )
+ """)
+
+ frappe.db.sql("DELETE FROM `tabTimesheet` WHERE company='_Test Company'")
+ frappe.db.sql(f"DELETE FROM `tabProject` WHERE name='{cls.test_project.name}'")
+
+ def test_utilization_report_with_required_filters_only(self):
+ filters = {
+ "company": "_Test Company",
+ "from_date": "2021-04-01",
+ "to_date": "2021-04-03"
+ }
+
+ report = execute(filters)
+
+ expected_data = self.get_expected_data_for_test_employees()
+ self.assertEqual(report[1], expected_data)
+
+ def test_utilization_report_for_single_employee(self):
+ filters = {
+ "company": "_Test Company",
+ "from_date": "2021-04-01",
+ "to_date": "2021-04-03",
+ "employee": self.test_emp1
+ }
+
+ report = execute(filters)
+
+ emp1_data = frappe.get_doc('Employee', self.test_emp1)
+ expected_data = [
+ {
+ 'employee': self.test_emp1,
+ 'employee_name': 'test1@employeeutil.com',
+ 'billed_hours': 5.0,
+ 'non_billed_hours': 0.0,
+ 'department': emp1_data.department,
+ 'total_hours': 18.0,
+ 'untracked_hours': 13.0,
+ 'per_util': 27.78,
+ 'per_util_billed_only': 27.78
+ }
+ ]
+
+ self.assertEqual(report[1], expected_data)
+
+ def test_utilization_report_for_project(self):
+ filters = {
+ "company": "_Test Company",
+ "from_date": "2021-04-01",
+ "to_date": "2021-04-03",
+ "project": self.test_project.name
+ }
+
+ report = execute(filters)
+
+ emp2_data = frappe.get_doc('Employee', self.test_emp2)
+ expected_data = [
+ {
+ 'employee': self.test_emp2,
+ 'employee_name': 'test2@employeeutil.com',
+ 'billed_hours': 0.0,
+ 'non_billed_hours': 10.0,
+ 'department': emp2_data.department,
+ 'total_hours': 18.0,
+ 'untracked_hours': 8.0,
+ 'per_util': 55.56,
+ 'per_util_billed_only': 0.0
+ }
+ ]
+
+ self.assertEqual(report[1], expected_data)
+
+ def test_utilization_report_for_department(self):
+ emp1_data = frappe.get_doc('Employee', self.test_emp1)
+ filters = {
+ "company": "_Test Company",
+ "from_date": "2021-04-01",
+ "to_date": "2021-04-03",
+ "department": emp1_data.department
+ }
+
+ report = execute(filters)
+
+ expected_data = self.get_expected_data_for_test_employees()
+ self.assertEqual(report[1], expected_data)
+
+ def test_report_summary_data(self):
+ filters = {
+ "company": "_Test Company",
+ "from_date": "2021-04-01",
+ "to_date": "2021-04-03"
+ }
+
+ report = execute(filters)
+ summary = report[4]
+ expected_summary_values = ['41.67%', '13.89%', 5.0, 10.0]
+
+ self.assertEqual(len(summary), 4)
+
+ for i in range(4):
+ self.assertEqual(
+ summary[i]['value'], expected_summary_values[i]
+ )
+
+ def get_expected_data_for_test_employees(self):
+ emp1_data = frappe.get_doc('Employee', self.test_emp1)
+ emp2_data = frappe.get_doc('Employee', self.test_emp2)
+
+ return [
+ {
+ 'employee': self.test_emp2,
+ 'employee_name': 'test2@employeeutil.com',
+ 'billed_hours': 0.0,
+ 'non_billed_hours': 10.0,
+ 'department': emp2_data.department,
+ 'total_hours': 18.0,
+ 'untracked_hours': 8.0,
+ 'per_util': 55.56,
+ 'per_util_billed_only': 0.0
+ },
+ {
+ 'employee': self.test_emp1,
+ 'employee_name': 'test1@employeeutil.com',
+ 'billed_hours': 5.0,
+ 'non_billed_hours': 0.0,
+ 'department': emp1_data.department,
+ 'total_hours': 18.0,
+ 'untracked_hours': 13.0,
+ 'per_util': 27.78,
+ 'per_util_billed_only': 27.78
+ }
+ ]
\ No newline at end of file
diff --git a/erpnext/projects/report/project_profitability/test_project_profitability.py b/erpnext/projects/report/project_profitability/test_project_profitability.py
index 251b71d..7fe28b1 100644
--- a/erpnext/projects/report/project_profitability/test_project_profitability.py
+++ b/erpnext/projects/report/project_profitability/test_project_profitability.py
@@ -3,7 +3,7 @@
import frappe
from frappe.utils import getdate, nowdate
from erpnext.hr.doctype.employee.test_employee import make_employee
-from erpnext.projects.doctype.timesheet.test_timesheet import make_salary_structure_for_timesheet, make_timesheet
+from erpnext.projects.doctype.timesheet.test_timesheet import make_salary_structure_for_timesheet, make_timesheet
from erpnext.projects.doctype.timesheet.timesheet import make_salary_slip, make_sales_invoice
from erpnext.projects.report.project_profitability.project_profitability import execute
@@ -21,6 +21,8 @@
self.sales_invoice.due_date = nowdate()
self.sales_invoice.submit()
+ frappe.db.set_value("HR Settings", "HR Settings", "standard_working_hours", 8)
+
def test_project_profitability(self):
filters = {
'company': '_Test Company',
@@ -43,7 +45,7 @@
standard_working_hours = frappe.db.get_single_value("HR Settings", "standard_working_hours")
utilization = timesheet.total_billed_hours/(self.salary_slip.total_working_days * standard_working_hours)
self.assertEqual(utilization, row.utilization)
-
+
profit = self.sales_invoice.base_grand_total - self.salary_slip.base_gross_pay * utilization
self.assertEqual(profit, row.profit)
diff --git a/erpnext/projects/workspace/projects/projects.json b/erpnext/projects/workspace/projects/projects.json
index b65e9aa..c023a73 100644
--- a/erpnext/projects/workspace/projects/projects.json
+++ b/erpnext/projects/workspace/projects/projects.json
@@ -131,6 +131,16 @@
"type": "Link"
},
{
+ "dependencies": "Timesheet",
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "Employee Hours Utilization",
+ "link_to": "Employee Hours Utilization Based On Timesheet",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
"dependencies": "Timesheet, Sales Invoice, Salary Slip",
"hidden": 0,
"is_query_report": 1,
@@ -171,7 +181,7 @@
"type": "Link"
}
],
- "modified": "2021-04-16 16:27:16.548780",
+ "modified": "2021-04-25 16:27:16.548780",
"modified_by": "Administrator",
"module": "Projects",
"name": "Projects",