From a5f09de444613ee51a31ed1b04d3fa50133e4eec Mon Sep 17 00:00:00 2001 From: wreckage0907 Date: Tue, 2 Jun 2026 18:15:03 +0530 Subject: [PATCH 1/3] refactor: project timesheet api --- next_pms/timesheet/api/project.py | 108 +++++++++++++++++++++++++----- next_pms/timesheet/api/utils.py | 105 +++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+), 17 deletions(-) diff --git a/next_pms/timesheet/api/project.py b/next_pms/timesheet/api/project.py index cae13ea1c..1370517e7 100644 --- a/next_pms/timesheet/api/project.py +++ b/next_pms/timesheet/api/project.py @@ -1,5 +1,4 @@ import json -from functools import partial import frappe from erpnext.accounts.report.utils import get_rate_as_at @@ -8,13 +7,14 @@ from next_pms.api.utils import error_logger -from . import get_count +from . import filter_employees, get_count from .utils import ( build_aggregate_dates, + build_chunk_context, build_employee_week_details, - get_project_candidate_employee_ids, + get_employees_for_projects, + get_qualifying_project_ids, normalize_status_filter, - paginate_qualifying_employee_payloads, parse_filters, ) @@ -157,12 +157,12 @@ def _prepare_project_timesheet_context( "has_filters": has_filters, "dates": dates, "response_dates": _get_project_response_dates(dates, max_week, has_filters), - "candidate_employee_ids": get_project_candidate_employee_ids( - reports_to=reports_to, + "candidate_project_ids": get_qualifying_project_ids( dates=dates, parsed_filters=parsed_filters, search=search, approval_status=approval_statuses, + reports_to=reports_to, ), } @@ -344,39 +344,113 @@ def get_project_timesheet_data( approval_statuses=approval_statuses, ) - if project_context["candidate_employee_ids"] == []: + all_project_ids = project_context["candidate_project_ids"] + if not all_project_ids: return { "week_groups": _build_project_week_groups(project_context["response_dates"], {}), "total_count": 0, "has_more": False, } - selected_employees, total_count, has_more = paginate_qualifying_employee_payloads( - reports_to=reports_to, - employee_ids=project_context["candidate_employee_ids"], + # Paginate project IDs directly — no DB round-trip needed. + total_count = len(all_project_ids) + selected_project_ids = all_project_ids[start : start + page_length] + has_more = start + page_length < total_count + + # Get the employees who logged time to this page's projects. + employee_ids = get_employees_for_projects( + project_ids=selected_project_ids, + dates=project_context["dates"], + parsed_filters=project_context["parsed_filters"], + approval_status=approval_statuses, + ) + if not employee_ids: + return { + "week_groups": _build_project_week_groups(project_context["response_dates"], {}), + "total_count": total_count, + "has_more": has_more, + } + + employees, _ = filter_employees(page_length=len(employee_ids), start=0, ids=employee_ids) + context = build_chunk_context( + employees=employees, dates=project_context["dates"], parsed_filters=project_context["parsed_filters"], search=search, - start=start, - page_length=page_length, - builder=partial( - _build_project_employee_payload, + ) + + employee_data_map = {} + for employee in employees: + payload = _build_project_employee_payload( + employee=employee, + context=context, dates=project_context["dates"], response_dates=project_context["response_dates"], has_filters=project_context["has_filters"], skip_empty_weeks=skip_empty_weeks, approval_statuses=approval_statuses, - ), - ) + ) + if payload: + emp_name, emp_data = payload + employee_data_map[emp_name] = emp_data - employee_data_map = {employee_name: payload for employee_name, payload in selected_employees} week_groups = _build_project_week_groups(project_context["response_dates"], employee_data_map) if project_context["has_filters"] and skip_empty_weeks: week_groups = [week_group for week_group in week_groups if week_group.get("projects")] + # Restrict each week to only the selected projects — employees may have + # logged to other projects that should appear on a different page. + selected_project_set = set(selected_project_ids) + for week_group in week_groups: + week_group["projects"] = [p for p in week_group["projects"] if p["project"] in selected_project_set] + return { "week_groups": week_groups, "total_count": total_count, "has_more": has_more, } + + +@whitelist(methods=["GET"]) +@error_logger +def get_project_timesheet_pending_count( + date: str, + max_week: int = 2, + reports_to: str | None = None, +): + """Return the count of employees with at least one 'Approval Pending' week for project tasks.""" + only_for(["Timesheet Manager", "Timesheet User", "Projects Manager"], message=True) + + dates, _ = build_aggregate_dates(date=date, max_week=int(max_week), has_filters=False) + if not dates: + return {"count": 0} + + ts_filters = { + "start_date": [">=", dates[0]["start_date"]], + "end_date": ["<=", dates[-1]["end_date"]], + "docstatus": ["!=", 2], + "custom_weekly_approval_status": "Approval Pending", + } + timesheets = frappe.get_all("Timesheet", filters=ts_filters, fields=["name", "employee"]) + if not timesheets: + return {"count": 0} + + if reports_to: + report_employees = set(frappe.get_all("Employee", filters={"reports_to": reports_to}, pluck="name")) + timesheets = [ts for ts in timesheets if ts.employee in report_employees] + if not timesheets: + return {"count": 0} + + ts_names = [ts.name for ts in timesheets] + matched_parents = { + d.parent + for d in frappe.get_all( + "Timesheet Detail", filters={"parent": ["in", ts_names], "task": ["!=", ""]}, fields=["parent"] + ) + } + if not matched_parents: + return {"count": 0} + + count = len({ts.employee for ts in timesheets if ts.name in matched_parents}) + return {"count": count} diff --git a/next_pms/timesheet/api/utils.py b/next_pms/timesheet/api/utils.py index 17660be83..f59a197dc 100644 --- a/next_pms/timesheet/api/utils.py +++ b/next_pms/timesheet/api/utils.py @@ -506,6 +506,111 @@ def get_project_candidate_employee_ids( return employee_ids +def get_qualifying_project_ids( + dates: list, + parsed_filters: dict | None = None, + search: str | None = None, + approval_status: list[str] | None = None, + reports_to: str | None = None, +) -> list[str]: + """Return a sorted list of distinct project IDs that have timesheet entries in the date range. + + Query chain: Timesheet → (Employee for reports_to) → Timesheet Detail → Task → distinct projects. + All intermediate empty results short-circuit to []. + """ + if not dates: + return [] + + parsed_filters = parsed_filters or {dt: [] for dt in ALLOWED_FILTER_FIELDS} + + base_ts_filters = { + "start_date": [">=", dates[0].get("start_date")], + "end_date": ["<=", dates[-1].get("end_date")], + "docstatus": ["!=", 2], + } + if approval_status: + base_ts_filters["custom_weekly_approval_status"] = ["in", approval_status] + + ts_filters = build_filters(base_ts_filters, parsed_filters.get("Timesheet", [])) + timesheets = get_all("Timesheet", filters=ts_filters, fields=["name", "employee"]) + if not timesheets: + return [] + + if reports_to: + report_employee_names = set(get_all("Employee", filters={"reports_to": reports_to}, pluck="name")) + timesheets = [ts for ts in timesheets if ts.employee in report_employee_names] + if not timesheets: + return [] + + ts_names = [ts.name for ts in timesheets] + base_detail_filters = {"parent": ["in", ts_names]} + detail_filters = build_filters(base_detail_filters, parsed_filters.get("Timesheet Detail", [])) + details = get_all("Timesheet Detail", filters=detail_filters, fields=["task"]) + task_ids = list({d.task for d in details if d.task}) + if not task_ids: + return [] + + base_task_filters = {"name": ["in", task_ids], "project": ["!=", ""]} + task_filters = build_filters(base_task_filters, parsed_filters.get("Task", [])) + tasks = get_all("Task", filters=task_filters, fields=TASK_FIELDS) + + if search: + search_lower = search.lower() + tasks = [ + t + for t in tasks + if search_lower in (t.get("subject") or "").lower() + or search_lower in (t.get("name") or "").lower() + or search_lower in (t.get("project_name") or "").lower() + ] + + project_ids = sorted({t.project for t in tasks if t.get("project")}) + return project_ids + + +def get_employees_for_projects( + project_ids: list[str], + dates: list, + parsed_filters: dict | None = None, + approval_status: list[str] | None = None, +) -> list[str]: + """Return distinct employee IDs who logged time to any of the given projects in the date range. + + Query chain: Task (project IN project_ids) → Timesheet Detail → Timesheet → distinct employees. + """ + if not project_ids or not dates: + return [] + + parsed_filters = parsed_filters or {dt: [] for dt in ALLOWED_FILTER_FIELDS} + + task_ids = get_all("Task", filters={"project": ["in", project_ids]}, pluck="name") + if not task_ids: + return [] + + ts_names_from_details = list( + { + d.parent + for d in get_all("Timesheet Detail", filters={"task": ["in", task_ids]}, fields=["parent"]) + if d.parent + } + ) + if not ts_names_from_details: + return [] + + base_ts_filters = { + "name": ["in", ts_names_from_details], + "start_date": [">=", dates[0].get("start_date")], + "end_date": ["<=", dates[-1].get("end_date")], + "docstatus": ["!=", 2], + } + if approval_status: + base_ts_filters["custom_weekly_approval_status"] = ["in", approval_status] + + ts_filters = build_filters(base_ts_filters, parsed_filters.get("Timesheet", [])) + timesheets = get_all("Timesheet", filters=ts_filters, pluck="employee") + return list(set(timesheets)) + + def iter_employee_chunks(employees: list, chunk_size: int = EMPLOYEE_SCAN_CHUNK_SIZE): for index in range(0, len(employees), chunk_size): yield employees[index : index + chunk_size] From a29b0f037fa878945eb1c7634b50d7872f7e679a Mon Sep 17 00:00:00 2001 From: wreckage0907 Date: Wed, 3 Jun 2026 23:19:27 +0530 Subject: [PATCH 2/3] fix: remove unused filter in proj ts api --- next_pms/timesheet/api/project.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/next_pms/timesheet/api/project.py b/next_pms/timesheet/api/project.py index 1370517e7..8ed04d512 100644 --- a/next_pms/timesheet/api/project.py +++ b/next_pms/timesheet/api/project.py @@ -143,7 +143,6 @@ def _get_project_response_dates(dates: list, max_week: int, has_filters: bool): def _prepare_project_timesheet_context( date: str, max_week: int, - reports_to: str | None, filters: str | list | None, search: str | None, approval_statuses: list[str] | None, @@ -162,7 +161,6 @@ def _prepare_project_timesheet_context( parsed_filters=parsed_filters, search=search, approval_status=approval_statuses, - reports_to=reports_to, ), } @@ -316,7 +314,6 @@ def _build_project_week_groups(response_dates: list, employee_data_map: dict): def get_project_timesheet_data( date: str, max_week: int = 2, - reports_to: str | None = None, page_length: int = 10, start: int = 0, filters: str | list | None = None, @@ -338,7 +335,6 @@ def get_project_timesheet_data( project_context = _prepare_project_timesheet_context( date=date, max_week=max_week, - reports_to=reports_to, filters=filters, search=search, approval_statuses=approval_statuses, @@ -417,7 +413,6 @@ def get_project_timesheet_data( def get_project_timesheet_pending_count( date: str, max_week: int = 2, - reports_to: str | None = None, ): """Return the count of employees with at least one 'Approval Pending' week for project tasks.""" only_for(["Timesheet Manager", "Timesheet User", "Projects Manager"], message=True) @@ -436,12 +431,6 @@ def get_project_timesheet_pending_count( if not timesheets: return {"count": 0} - if reports_to: - report_employees = set(frappe.get_all("Employee", filters={"reports_to": reports_to}, pluck="name")) - timesheets = [ts for ts in timesheets if ts.employee in report_employees] - if not timesheets: - return {"count": 0} - ts_names = [ts.name for ts in timesheets] matched_parents = { d.parent From 0ea396a860dae201ec77e8a97de8d90e674fb33c Mon Sep 17 00:00:00 2001 From: wreckage0907 Date: Fri, 5 Jun 2026 12:35:30 +0530 Subject: [PATCH 3/3] fix: approval status and filtering issues --- next_pms/timesheet/api/project.py | 62 +++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 19 deletions(-) diff --git a/next_pms/timesheet/api/project.py b/next_pms/timesheet/api/project.py index 8ed04d512..791164080 100644 --- a/next_pms/timesheet/api/project.py +++ b/next_pms/timesheet/api/project.py @@ -125,19 +125,27 @@ def _normalize_project_timesheet_inputs( skip_empty_weeks: bool | str, ): return ( - int(start), - int(page_length), + max(0, int(start)), + max(0, int(page_length)), int(max_week), normalize_status_filter(approval_status, coerce_non_list=True), _coerce_project_skip_empty_weeks(skip_empty_weeks), ) -def _get_project_response_dates(dates: list, max_week: int, has_filters: bool): - if has_filters and len(dates) > max_week: - return dates[-max_week:] +def _apply_employee_filters(parsed_filters: dict): + """Translate Employee-level filters into a Timesheet ``employee IN (...)`` constraint. - return dates + The qualifying-project, employee-selection and render queries all read + ``parsed_filters["Timesheet"]``, so resolving Employee filters to employee IDs here + applies them consistently everywhere instead of being silently dropped. + """ + employee_filters = parsed_filters.get("Employee") + if not employee_filters: + return + + employee_names = frappe.get_all("Employee", filters=employee_filters, pluck="name") + parsed_filters["Timesheet"] = [*parsed_filters.get("Timesheet", []), ["employee", "in", employee_names]] def _prepare_project_timesheet_context( @@ -149,13 +157,17 @@ def _prepare_project_timesheet_context( ): parsed_filters = parse_filters(filters) has_filters = bool(search or approval_statuses or any(parsed_filters.values())) + _apply_employee_filters(parsed_filters) dates, _ = build_aggregate_dates(date=date, max_week=max_week, has_filters=has_filters) return { "parsed_filters": parsed_filters, "has_filters": has_filters, "dates": dates, - "response_dates": _get_project_response_dates(dates, max_week, has_filters), + # Under filters, `dates` spans the full (up to 12-week) lookback used to locate + # matching data and empty weeks are dropped downstream — render that whole span so + # data in older weeks is not hidden by trimming to the most recent `max_week`. + "response_dates": dates, "candidate_project_ids": get_qualifying_project_ids( dates=dates, parsed_filters=parsed_filters, @@ -348,10 +360,12 @@ def get_project_timesheet_data( "has_more": False, } - # Paginate project IDs directly — no DB round-trip needed. + # Paginate project IDs directly — no DB round-trip needed. `start`/`page_length` are + # already clamped to >= 0, so an empty/zero page can never report `has_more`. total_count = len(all_project_ids) - selected_project_ids = all_project_ids[start : start + page_length] - has_more = start + page_length < total_count + end = start + page_length + selected_project_ids = all_project_ids[start:end] + has_more = page_length > 0 and end < total_count # Get the employees who logged time to this page's projects. employee_ids = get_employees_for_projects( @@ -392,15 +406,18 @@ def get_project_timesheet_data( week_groups = _build_project_week_groups(project_context["response_dates"], employee_data_map) - if project_context["has_filters"] and skip_empty_weeks: - week_groups = [week_group for week_group in week_groups if week_group.get("projects")] - # Restrict each week to only the selected projects — employees may have # logged to other projects that should appear on a different page. selected_project_set = set(selected_project_ids) for week_group in week_groups: week_group["projects"] = [p for p in week_group["projects"] if p["project"] in selected_project_set] + # Under filters the render span is the full lookback, so collapse it to the weeks that + # actually hold matching data — otherwise older empty weeks would pad the response. + # Runs after the project restriction so weeks emptied by it are dropped too. + if project_context["has_filters"]: + week_groups = [week_group for week_group in week_groups if week_group.get("projects")] + return { "week_groups": week_groups, "total_count": total_count, @@ -432,12 +449,19 @@ def get_project_timesheet_pending_count( return {"count": 0} ts_names = [ts.name for ts in timesheets] - matched_parents = { - d.parent - for d in frappe.get_all( - "Timesheet Detail", filters={"parent": ["in", ts_names], "task": ["!=", ""]}, fields=["parent"] - ) - } + details = frappe.get_all( + "Timesheet Detail", filters={"parent": ["in", ts_names], "task": ["!=", ""]}, fields=["parent", "task"] + ) + task_ids = list({d.task for d in details if d.task}) + if not task_ids: + return {"count": 0} + + # Only count tasks that belong to a project, matching the project-timesheet view's + # qualification (a task with no project never appears there). + project_task_ids = set( + frappe.get_all("Task", filters={"name": ["in", task_ids], "project": ["!=", ""]}, pluck="name") + ) + matched_parents = {d.parent for d in details if d.task in project_task_ids} if not matched_parents: return {"count": 0}