diff --git a/erpnext/hr/doctype/hr_settings/hr_settings.json b/erpnext/hr/doctype/hr_settings/hr_settings.json index 09666c5db5b..35532291a5f 100644 --- a/erpnext/hr/doctype/hr_settings/hr_settings.json +++ b/erpnext/hr/doctype/hr_settings/hr_settings.json @@ -10,6 +10,7 @@ "retirement_age", "emp_created_by", "column_break_4", + "standard_working_hours", "stop_birthday_reminders", "expense_approver_mandatory_in_expense_claim", "leave_settings", @@ -143,13 +144,19 @@ "fieldname": "send_leave_notification", "fieldtype": "Check", "label": "Send Leave Notification" + }, + { + "default": "8", + "fieldname": "standard_working_hours", + "fieldtype": "Int", + "label": "Standard Working Hours" } ], "icon": "fa fa-cog", "idx": 1, "issingle": 1, "links": [], - "modified": "2021-03-14 02:04:22.907159", + "modified": "2021-04-16 15:45:18.467699", "modified_by": "Administrator", "module": "HR", "name": "HR Settings", diff --git a/erpnext/projects/doctype/timesheet/test_timesheet.py b/erpnext/projects/doctype/timesheet/test_timesheet.py index f7c764e1bd2..d21ac0f2f02 100644 --- a/erpnext/projects/doctype/timesheet/test_timesheet.py +++ b/erpnext/projects/doctype/timesheet/test_timesheet.py @@ -151,11 +151,11 @@ class TestTimesheet(unittest.TestCase): settings.save() -def make_salary_structure_for_timesheet(employee): +def make_salary_structure_for_timesheet(employee, company=None): salary_structure_name = "Timesheet Salary Structure Test" frequency = "Monthly" - salary_structure = make_salary_structure(salary_structure_name, frequency, dont_submit=True) + salary_structure = make_salary_structure(salary_structure_name, frequency, company=company, dont_submit=True) salary_structure.salary_component = "Timesheet Component" salary_structure.salary_slip_based_on_timesheet = 1 salary_structure.hour_rate = 50.0 diff --git a/erpnext/projects/report/project_profitability/__init__.py b/erpnext/projects/report/project_profitability/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/erpnext/projects/report/project_profitability/project_profitability.js b/erpnext/projects/report/project_profitability/project_profitability.js new file mode 100644 index 00000000000..13ae19bb299 --- /dev/null +++ b/erpnext/projects/report/project_profitability/project_profitability.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["Project Profitability"] = { + "filters": [ + { + "fieldname": "company", + "label": __("Company"), + "fieldtype": "Link", + "options": "Company", + "default": frappe.defaults.get_user_default("Company"), + "reqd": 1 + }, + { + "fieldname": "start_date", + "label": __("Start Date"), + "fieldtype": "Date", + "reqd": 1, + "default": frappe.datetime.add_months(frappe.datetime.get_today(), -1) + }, + { + "fieldname": "end_date", + "label": __("End Date"), + "fieldtype": "Date", + "reqd": 1, + "default": frappe.datetime.now_date() + }, + { + "fieldname": "customer_name", + "label": __("Customer"), + "fieldtype": "Link", + "options": "Customer" + }, + { + "fieldname": "employee", + "label": __("Employee"), + "fieldtype": "Link", + "options": "Employee" + }, + { + "fieldname": "project", + "label": __("Project"), + "fieldtype": "Link", + "options": "Project" + } + ] +}; diff --git a/erpnext/projects/report/project_profitability/project_profitability.json b/erpnext/projects/report/project_profitability/project_profitability.json new file mode 100644 index 00000000000..0b092cd2c09 --- /dev/null +++ b/erpnext/projects/report/project_profitability/project_profitability.json @@ -0,0 +1,44 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2021-04-16 15:50:28.914872", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "modified": "2021-04-16 15:50:48.490866", + "modified_by": "Administrator", + "module": "Projects", + "name": "Project Profitability", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Timesheet", + "report_name": "Project Profitability", + "report_type": "Script Report", + "roles": [ + { + "role": "HR User" + }, + { + "role": "Accounts User" + }, + { + "role": "Employee" + }, + { + "role": "Projects User" + }, + { + "role": "Manufacturing User" + }, + { + "role": "Employee Self Service" + }, + { + "role": "HR Manager" + } + ] +} \ No newline at end of file diff --git a/erpnext/projects/report/project_profitability/project_profitability.py b/erpnext/projects/report/project_profitability/project_profitability.py new file mode 100644 index 00000000000..5ad2d852326 --- /dev/null +++ b/erpnext/projects/report/project_profitability/project_profitability.py @@ -0,0 +1,210 @@ +# 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 _ + +def execute(filters=None): + columns, data = [], [] + data = get_data(filters) + columns = get_columns() + charts = get_chart_data(data) + return columns, data, None, charts + +def get_data(filters): + data = get_rows(filters) + data = calculate_cost_and_profit(data) + return data + +def get_rows(filters): + conditions = get_conditions(filters) + standard_working_hours = frappe.db.get_single_value("HR Settings", "standard_working_hours") + if not 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.msgprint(msg) + return [] + + sql = """ + SELECT + * + FROM + (SELECT + si.customer_name,si.base_grand_total, + si.name as voucher_no,tabTimesheet.employee, + tabTimesheet.title as employee_name,tabTimesheet.parent_project as project, + tabTimesheet.start_date,tabTimesheet.end_date, + tabTimesheet.total_billed_hours,tabTimesheet.name as timesheet, + ss.base_gross_pay,ss.total_working_days, + tabTimesheet.total_billed_hours/(ss.total_working_days * {0}) as utilization + FROM + `tabSalary Slip Timesheet` as sst join `tabTimesheet` on tabTimesheet.name = sst.time_sheet + join `tabSales Invoice Timesheet` as sit on sit.time_sheet = tabTimesheet.name + join `tabSales Invoice` as si on si.name = sit.parent and si.status != "Cancelled" + join `tabSalary Slip` as ss on ss.name = sst.parent and ss.status != "Cancelled" """.format(standard_working_hours) + if conditions: + sql += """ + WHERE + {0}) as t""".format(conditions) + return frappe.db.sql(sql,filters, as_dict=True) + +def calculate_cost_and_profit(data): + for row in data: + row.fractional_cost = row.base_gross_pay * row.utilization + row.profit = row.base_grand_total - row.base_gross_pay * row.utilization + return data + +def get_conditions(filters): + conditions = [] + + if filters.get("company"): + conditions.append("tabTimesheet.company={0}".format(frappe.db.escape(filters.get("company")))) + + if filters.get("start_date"): + conditions.append("tabTimesheet.start_date>='{0}'".format(filters.get("start_date"))) + + if filters.get("end_date"): + conditions.append("tabTimesheet.end_date<='{0}'".format(filters.get("end_date"))) + + if filters.get("customer_name"): + conditions.append("si.customer_name={0}".format(frappe.db.escape(filters.get("customer_name")))) + + if filters.get("employee"): + conditions.append("tabTimesheet.employee={0}".format(frappe.db.escape(filters.get("employee")))) + + if filters.get("project"): + conditions.append("tabTimesheet.parent_project={0}".format(frappe.db.escape(filters.get("project")))) + + conditions = " and ".join(conditions) + return conditions + +def get_chart_data(data): + if not data: + return None + + labels = [] + utilization = [] + + for entry in data: + labels.append(entry.get("employee_name") + " - " + str(entry.get("end_date"))) + utilization.append(entry.get("utilization")) + + charts = { + "data": { + "labels": labels, + "datasets": [ + { + "name": "Utilization", + "values": utilization + } + ] + }, + "type": "bar", + "colors": ["#84BDD5"] + } + return charts + +def get_columns(): + return [ + { + "fieldname": "customer_name", + "label": _("Customer"), + "fieldtype": "Link", + "options": "Customer", + "width": 150 + }, + { + "fieldname": "employee", + "label": _("Employee"), + "fieldtype": "Link", + "options": "Employee", + "width": 130 + }, + { + "fieldname": "employee_name", + "label": _("Employee Name"), + "fieldtype": "Data", + "width": 120 + }, + { + "fieldname": "voucher_no", + "label": _("Sales Invoice"), + "fieldtype": "Link", + "options": "Sales Invoice", + "width": 120 + }, + { + "fieldname": "timesheet", + "label": _("Timesheet"), + "fieldtype": "Link", + "options": "Timesheet", + "width": 120 + }, + { + "fieldname": "project", + "label": _("Project"), + "fieldtype": "Link", + "options": "Project", + "width": 100 + }, + { + "fieldname": "base_grand_total", + "label": _("Bill Amount"), + "fieldtype": "Currency", + "options": "currency", + "width": 100 + }, + { + "fieldname": "base_gross_pay", + "label": _("Cost"), + "fieldtype": "Currency", + "options": "currency", + "width": 100 + }, + { + "fieldname": "profit", + "label": _("Profit"), + "fieldtype": "Currency", + "options": "currency", + "width": 100 + }, + { + "fieldname": "utilization", + "label": _("Utilization"), + "fieldtype": "Percentage", + "width": 100 + }, + { + "fieldname": "fractional_cost", + "label": _("Fractional Cost"), + "fieldtype": "Int", + "width": 120 + }, + { + "fieldname": "total_billed_hours", + "label": _("Total Billed Hours"), + "fieldtype": "Int", + "width": 150 + }, + { + "fieldname": "start_date", + "label": _("Start Date"), + "fieldtype": "Date", + "width": 100 + }, + { + "fieldname": "end_date", + "label": _("End Date"), + "fieldtype": "Date", + "width": 100 + }, + { + "label": _("Currency"), + "fieldname": "currency", + "fieldtype": "Link", + "options": "Currency", + "width": 80 + } + ] \ 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 new file mode 100644 index 00000000000..251b71da598 --- /dev/null +++ b/erpnext/projects/report/project_profitability/test_project_profitability.py @@ -0,0 +1,56 @@ +from __future__ import unicode_literals +import unittest +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.timesheet import make_salary_slip, make_sales_invoice +from erpnext.projects.report.project_profitability.project_profitability import execute + +class TestProjectProfitability(unittest.TestCase): + @classmethod + def setUp(self): + emp = make_employee('test_employee_9@salary.com', company='_Test Company') + if not frappe.db.exists('Salary Component', 'Timesheet Component'): + frappe.get_doc({'doctype': 'Salary Component', 'salary_component': 'Timesheet Component'}).insert() + make_salary_structure_for_timesheet(emp, company='_Test Company') + self.timesheet = make_timesheet(emp, simulate = True, billable=1) + self.salary_slip = make_salary_slip(self.timesheet.name) + self.salary_slip.submit() + self.sales_invoice = make_sales_invoice(self.timesheet.name, '_Test Item', '_Test Customer') + self.sales_invoice.due_date = nowdate() + self.sales_invoice.submit() + + def test_project_profitability(self): + filters = { + 'company': '_Test Company', + 'start_date': getdate(), + 'end_date': getdate() + } + + report = execute(filters) + + row = report[1][0] + timesheet = frappe.get_doc("Timesheet", self.timesheet.name) + + self.assertEqual(self.sales_invoice.customer, row.customer_name) + self.assertEqual(timesheet.title, row.employee_name) + self.assertEqual(self.sales_invoice.base_grand_total, row.base_grand_total) + self.assertEqual(self.salary_slip.base_gross_pay, row.base_gross_pay) + self.assertEqual(timesheet.total_billed_hours, row.total_billed_hours) + self.assertEqual(self.salary_slip.total_working_days, row.total_working_days) + + 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) + + fractional_cost = self.salary_slip.base_gross_pay * utilization + self.assertEqual(fractional_cost, row.fractional_cost) + + def tearDown(self): + frappe.get_doc("Sales Invoice", self.sales_invoice.name).cancel() + frappe.get_doc("Salary Slip", self.salary_slip.name).cancel() + frappe.get_doc("Timesheet", self.timesheet.name).cancel() \ No newline at end of file diff --git a/erpnext/projects/workspace/projects/projects.json b/erpnext/projects/workspace/projects/projects.json index 0ec17029a23..b65e9aa7808 100644 --- a/erpnext/projects/workspace/projects/projects.json +++ b/erpnext/projects/workspace/projects/projects.json @@ -130,6 +130,16 @@ "onboard": 1, "type": "Link" }, + { + "dependencies": "Timesheet, Sales Invoice, Salary Slip", + "hidden": 0, + "is_query_report": 1, + "label": "Project Profitability", + "link_to": "Project Profitability", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, { "dependencies": "Project", "hidden": 0, @@ -161,7 +171,7 @@ "type": "Link" } ], - "modified": "2021-03-26 16:32:00.628561", + "modified": "2021-04-16 16:27:16.548780", "modified_by": "Administrator", "module": "Projects", "name": "Projects",