diff --git a/erpnext/projects/report/timesheet_billing_summary/test_timesheet_billing_summary.py b/erpnext/projects/report/timesheet_billing_summary/test_timesheet_billing_summary.py index c4ae6ecd9b3..771fbb1ad41 100644 --- a/erpnext/projects/report/timesheet_billing_summary/test_timesheet_billing_summary.py +++ b/erpnext/projects/report/timesheet_billing_summary/test_timesheet_billing_summary.py @@ -2,9 +2,10 @@ # See license.txt import frappe +from frappe import _ from erpnext.projects.doctype.timesheet.test_timesheet import make_timesheet -from erpnext.projects.report.timesheet_billing_summary.timesheet_billing_summary import execute +from erpnext.projects.report.timesheet_billing_summary.timesheet_billing_summary import execute, group_by from erpnext.setup.doctype.employee.test_employee import make_employee from erpnext.tests.utils import ERPNextTestSuite @@ -28,10 +29,13 @@ class TestTimesheetBillingSummary(ERPNextTestSuite): self.employee, simulate=True, is_billable=is_billable, project=self.project.name ) - def run_report(self, **extra): + def execute_report(self, **extra): filters = frappe._dict({"company": "_Test Company", "employee": self.employee}) filters.update(extra) - return execute(filters)[1] + return execute(filters) + + def run_report(self, **extra): + return self.execute_report(**extra)[1] def test_billable_timesheet_row(self): ts = self.make_ts(is_billable=1) @@ -53,6 +57,80 @@ class TestTimesheetBillingSummary(ERPNextTestSuite): self.assertTrue(group_rows, "Grouped project row missing") self.assertEqual(group_rows[0]["hours"], 2) + def test_report_summary_totals(self): + self.make_ts(is_billable=1) + self.make_ts(is_billable=1) + + _columns, data, _message, _chart, report_summary, _skip_total_row = self.execute_report() + summary = {item["label"]: item["value"] for item in report_summary} + + self.assertFalse(any(row.get("timesheet") == "'Total'" for row in data)) + self.assertEqual(summary[_("Total Working Hours")], 4) + self.assertEqual(summary[_("Total Billing Hours")], 4) + self.assertEqual(summary[_("Total Billing Amount")], 200) + + def test_group_by_date_combines_entries_from_same_day(self): + data = [ + frappe._dict(date="2026-08-20 09:00:00", hours=2, billing_hours=2, billing_amount=100), + frappe._dict(date="2026-08-20 15:00:00", hours=3, billing_hours=3, billing_amount=150), + ] + + group_rows = [row for row in group_by(data, "date") if row.get("is_group")] + + self.assertEqual(len(group_rows), 1) + self.assertEqual(group_rows[0]["hours"], 5) + + def test_report_summary_respects_project_user_permission(self): + denied_project = frappe.get_doc( + { + "doctype": "Project", + "project_name": "_Test TBS Denied", + "company": "_Test Company", + } + ).insert() + self.make_ts(is_billable=1) + make_timesheet( + self.employee, + simulate=True, + is_billable=1, + project=denied_project.name, + ) + + user = frappe.get_doc( + { + "doctype": "User", + "email": "timesheet-summary@example.com", + "first_name": "Timesheet Summary", + "enabled": 1, + "send_welcome_email": 0, + "roles": [{"role": "Projects User"}, {"role": "Accounts User"}], + } + ).insert(ignore_permissions=True) + frappe.get_doc( + { + "doctype": "User Permission", + "user": user.name, + "allow": "Project", + "for_value": self.project.name, + } + ).insert(ignore_permissions=True) + frappe.clear_cache(user=user.name) + + try: + frappe.set_user(user.name) + _columns, data, _message, _chart, report_summary, _skip_total_row = self.execute_report( + group_by="project" + ) + finally: + frappe.set_user("Administrator") + + summary = {item["label"]: item["value"] for item in report_summary} + group_projects = {row.get("project") for row in data if row.get("is_group")} + self.assertEqual(group_projects, {self.project.name}) + self.assertEqual(summary[_("Total Working Hours")], 2) + self.assertEqual(summary[_("Total Billing Hours")], 2) + self.assertEqual(summary[_("Total Billing Amount")], 100) + def test_draft_excluded_unless_requested(self): ts = make_timesheet( self.employee, simulate=True, is_billable=1, project=self.project.name, do_not_submit=True diff --git a/erpnext/projects/report/timesheet_billing_summary/timesheet_billing_summary.py b/erpnext/projects/report/timesheet_billing_summary/timesheet_billing_summary.py index 2b1266a3c7c..5917f664ce4 100644 --- a/erpnext/projects/report/timesheet_billing_summary/timesheet_billing_summary.py +++ b/erpnext/projects/report/timesheet_billing_summary/timesheet_billing_summary.py @@ -1,5 +1,6 @@ import frappe from frappe import _ +from frappe.desk.query_report import get_filtered_data from frappe.model.docstatus import DocStatus from frappe.utils import getdate @@ -12,11 +13,14 @@ def execute(filters=None): filters = frappe._dict(filters or {}) columns = get_columns(filters, group_fieldname) - data = get_data(filters, group_fieldname) + data = get_data(filters) + data = get_filtered_data("Timesheet", columns, data, frappe.session.user) + report_summary = get_report_summary(data) - # the report totals itself in `get_total_row`: frappe's total row counts a group and its - # children as separate rows, and the datatable hides it anyway in tree mode - return columns, data, None, None, None, 1 + if group_fieldname: + data = group_by(data, group_fieldname) + + return columns, data, None, None, report_summary, 1 def get_columns(filters, group_fieldname=None): @@ -86,7 +90,7 @@ def get_columns(filters, group_fieldname=None): return columns -def get_data(filters, group_fieldname=None): +def get_data(filters): _filters = [] if filters.get("employee"): _filters.append(("employee", "=", filters.get("employee"))) @@ -117,13 +121,6 @@ def get_data(filters, group_fieldname=None): order_by="`tabTimesheet Detail`.from_time", ) - if not data: - return data - - if group_fieldname: - data = group_by(data, group_fieldname) - - data.append(get_total_row(data)) return data @@ -170,17 +167,32 @@ def get_group_value(row, fieldname): return getdate(value) if fieldname == "date" and value else value -def get_total_row(data): - total_row = dict.fromkeys(VALUE_FIELDNAMES, 0.0) - # quoted so that the Link column renders it as plain text instead of a broken link - total_row["timesheet"] = f"'{_('Total')}'" - total_row["indent"] = 0 +def get_report_summary(data): + if not data: + return None + totals = dict.fromkeys(VALUE_FIELDNAMES, 0.0) for row in data: - # a group already carries its children's values: adding both would count them twice - if row.get("indent"): - continue for value_fieldname in VALUE_FIELDNAMES: - total_row[value_fieldname] += row.get(value_fieldname) or 0 + totals[value_fieldname] += row.get(value_fieldname) or 0 - return total_row + return [ + { + "value": totals["hours"], + "indicator": "Blue", + "label": _("Total Working Hours"), + "datatype": "Float", + }, + { + "value": totals["billing_hours"], + "indicator": "Blue", + "label": _("Total Billing Hours"), + "datatype": "Float", + }, + { + "value": totals["billing_amount"], + "indicator": "Green", + "label": _("Total Billing Amount"), + "datatype": "Currency", + }, + ]