From 3d4245b9b44125ff7de18cb3988055b7bdca3667 Mon Sep 17 00:00:00 2001 From: "mergify[bot]" <37929162+mergify[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:46:36 +0530 Subject: [PATCH] fix: include time logs ending at midnight in timesheet billing summary (backport #58355) (#58356) Co-authored-by: Mihir Kandoi --- .../timesheet_billing_summary.py | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 erpnext/projects/report/timesheet_billing_summary/timesheet_billing_summary.py diff --git a/erpnext/projects/report/timesheet_billing_summary/timesheet_billing_summary.py b/erpnext/projects/report/timesheet_billing_summary/timesheet_billing_summary.py new file mode 100644 index 00000000000..70b3d0c0c80 --- /dev/null +++ b/erpnext/projects/report/timesheet_billing_summary/timesheet_billing_summary.py @@ -0,0 +1,198 @@ +import frappe +from frappe import _ +from frappe.desk.query_report import get_filtered_data +from frappe.model.docstatus import DocStatus +from frappe.utils import add_days, getdate + +VALUE_FIELDNAMES = ("hours", "billing_hours", "billing_amount") + + +def execute(filters=None): + group_fieldname = filters.pop("group_by", None) + + filters = frappe._dict(filters or {}) + columns = get_columns(filters, group_fieldname) + + data = get_data(filters) + data = get_filtered_data("Timesheet", columns, data, frappe.session.user) + report_summary = get_report_summary(data) + + if group_fieldname: + data = group_by(data, group_fieldname) + + return columns, data, None, None, report_summary, 1 + + +def get_columns(filters, group_fieldname=None): + group_columns = { + "date": { + "label": _("Date"), + "fieldtype": "Date", + "fieldname": "date", + "width": 150, + }, + "project": { + "label": _("Project"), + "fieldtype": "Link", + "fieldname": "project", + "options": "Project", + "width": 200, + "hidden": int(bool(filters.get("project"))), + }, + "employee": { + "label": _("Employee ID"), + "fieldtype": "Link", + "fieldname": "employee", + "options": "Employee", + "width": 200, + "hidden": int(bool(filters.get("employee"))), + }, + } + columns = [] + if group_fieldname in group_columns: + # the grouped column labels the group rows: keep it visible even when it is filtered too + group_columns[group_fieldname]["hidden"] = 0 + columns.append(group_columns.pop(group_fieldname)) + + columns.extend(group_columns.values()) + + columns.extend( + [ + { + "label": _("Employee Name"), + "fieldtype": "data", + "fieldname": "employee_name", + "hidden": 1, + }, + { + "label": _("Timesheet"), + "fieldtype": "Link", + "fieldname": "timesheet", + "options": "Timesheet", + "width": 150, + }, + {"label": _("Working Hours"), "fieldtype": "Float", "fieldname": "hours", "width": 150}, + { + "label": _("Billing Hours"), + "fieldtype": "Float", + "fieldname": "billing_hours", + "width": 150, + }, + { + "label": _("Billing Amount"), + "fieldtype": "Currency", + "fieldname": "billing_amount", + "width": 150, + }, + ] + ) + + return columns + + +def get_data(filters): + _filters = [] + if filters.get("employee"): + _filters.append(("employee", "=", filters.get("employee"))) + if filters.get("project"): + _filters.append(("Timesheet Detail", "project", "=", filters.get("project"))) + if filters.get("from_date"): + _filters.append(("Timesheet Detail", "from_time", ">=", filters.get("from_date"))) + if filters.get("to_date"): + _filters.append(("Timesheet Detail", "from_time", "<", add_days(getdate(filters.get("to_date")), 1))) + if not filters.get("include_draft_timesheets"): + _filters.append(("docstatus", "=", DocStatus.submitted())) + else: + _filters.append(("docstatus", "in", (DocStatus.submitted(), DocStatus.draft()))) + + data = frappe.get_list( + "Timesheet", + fields=[ + "name as timesheet", + "`tabTimesheet`.employee", + "`tabTimesheet`.employee_name", + "`tabTimesheet Detail`.from_time as date", + "`tabTimesheet Detail`.project", + "`tabTimesheet Detail`.hours", + "`tabTimesheet Detail`.billing_hours", + "`tabTimesheet Detail`.billing_amount", + ], + filters=_filters, + order_by="`tabTimesheet Detail`.from_time", + ) + + return data + + +def group_by(data, fieldname): + groups = {} + for row in data: + groups.setdefault(get_group_value(row, fieldname), []).append(row) + + grouped_data = [] + for group in sorted(groups, key=lambda g: (g is None, g)): + hours = billing_hours = billing_amount = 0 + child_rows = [] + for row in groups[group]: + hours += row.get("hours") or 0 + billing_hours += row.get("billing_hours") or 0 + billing_amount += row.get("billing_amount") or 0 + + _row = row.copy() + _row[fieldname] = None + _row["indent"] = 1 + _row["is_group"] = 0 + child_rows.append(_row) + + group_row = { + fieldname: group, + "hours": hours, + "billing_hours": billing_hours, + "billing_amount": billing_amount, + "indent": 0, + "is_group": 1, + } + if fieldname == "employee": + group_row["employee_name"] = groups[group][0].get("employee_name") + + grouped_data.append(group_row) + grouped_data.extend(child_rows) + + return grouped_data + + +def get_group_value(row, fieldname): + value = row.get(fieldname) + # `date` is `Timesheet Detail.from_time`, a datetime: everything logged on a day is one group + return getdate(value) if fieldname == "date" and value else value + + +def get_report_summary(data): + if not data: + return None + + totals = dict.fromkeys(VALUE_FIELDNAMES, 0.0) + for row in data: + for value_fieldname in VALUE_FIELDNAMES: + totals[value_fieldname] += row.get(value_fieldname) or 0 + + 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", + }, + ]