From b1af155af68776088d601db539d9e388d77d0fcb Mon Sep 17 00:00:00 2001 From: Krishna Shirsath Date: Tue, 14 Apr 2026 12:56:52 +0530 Subject: [PATCH 1/6] fix(monthly_attendance_sheet): update holiday list assignment logic for employees --- .../monthly_attendance_sheet/monthly_attendance_sheet.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/hrms/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py b/hrms/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py index b2f1c7c980..dfb3862eff 100644 --- a/hrms/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py +++ b/hrms/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py @@ -17,6 +17,7 @@ from frappe.utils.nestedset import get_descendants_of from hrms.utils import date_diff, get_date_range +from hrms.utils.holiday_list import get_holiday_list_for_employee Filters = frappe._dict @@ -445,10 +446,11 @@ def get_holiday_map(filters: Filters) -> dict[str, list[dict]]: def get_rows(employee_details: dict, filters: Filters, holiday_map: dict, attendance_map: dict) -> list[dict]: records = [] - default_holiday_list = frappe.get_cached_value("Company", filters.company, "default_holiday_list") - for employee, details in employee_details.items(): - emp_holiday_list = details.holiday_list or default_holiday_list + emp_holiday_list = get_holiday_list_for_employee( + employee, as_on=filters.end_date, raise_exception=False + ) + holidays = holiday_map.get(emp_holiday_list) if filters.summarized_view: From 5de5dab2e0a16c20c869b276f7e78624b147ee14 Mon Sep 17 00:00:00 2001 From: Krishna Shirsath Date: Mon, 20 Apr 2026 11:51:52 +0530 Subject: [PATCH 2/6] fix(monthly_attendance_sheet): enhance holiday list assignment handling for employees --- .../monthly_attendance_sheet.py | 44 ++++++- .../test_monthly_attendance_sheet.py | 110 +++++++++++++++++- hrms/utils/holiday_list.py | 103 ++++++++++++++++ 3 files changed, 250 insertions(+), 7 deletions(-) diff --git a/hrms/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py b/hrms/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py index dfb3862eff..6b2130aaa0 100644 --- a/hrms/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py +++ b/hrms/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py @@ -17,7 +17,7 @@ from frappe.utils.nestedset import get_descendants_of from hrms.utils import date_diff, get_date_range -from hrms.utils.holiday_list import get_holiday_list_for_employee +from hrms.utils.holiday_list import get_holiday_lists_for_employee_in_date_range Filters = frappe._dict @@ -444,14 +444,46 @@ def get_holiday_map(filters: Filters) -> dict[str, list[dict]]: return holiday_map +def get_holidays_for_employee(employee: str, filters: Filters, holiday_map: dict) -> list[dict]: + """ + Returns the merged list of holidays for an employee within the filter period, + correctly handling multiple holiday list assignments. + + For each assignment, only holidays that fall within the effective date range + of that assignment are included (from_date to effective to_date). + """ + start_date, end_date = get_date_range_from_filters(filters) + + hl_assignments = get_holiday_lists_for_employee_in_date_range(employee, start_date, end_date) + + if not hl_assignments: + return [] + + merged_holidays = [] + for assignment in hl_assignments: + hl_holidays = holiday_map.get(assignment["holiday_list"], []) + for holiday in hl_holidays: + h_date = getdate(holiday.holiday_date) + if assignment["from_date"] <= h_date <= assignment["to_date"]: + merged_holidays.append(holiday) + + return merged_holidays + + +def get_date_range_from_filters(filters: Filters) -> tuple: + """Returns (start_date, end_date) as date objects from filters.""" + if filters.filter_based_on == "Month": + total_days = get_total_days_in_month(filters) + start_date = getdate(f"{cstr(filters.year)}-{cstr(filters.month)}-01") + end_date = getdate(f"{cstr(filters.year)}-{cstr(filters.month)}-{total_days}") + return start_date, end_date + return getdate(filters.start_date), getdate(filters.end_date) + + def get_rows(employee_details: dict, filters: Filters, holiday_map: dict, attendance_map: dict) -> list[dict]: records = [] for employee, details in employee_details.items(): - emp_holiday_list = get_holiday_list_for_employee( - employee, as_on=filters.end_date, raise_exception=False - ) - - holidays = holiday_map.get(emp_holiday_list) + holidays = get_holidays_for_employee(employee, filters, holiday_map) if filters.summarized_view: attendance = get_attendance_status_for_summarized_view( diff --git a/hrms/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py b/hrms/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py index 9037dddbf5..bb45d141d7 100644 --- a/hrms/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py +++ b/hrms/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py @@ -6,7 +6,10 @@ from erpnext.setup.doctype.employee.test_employee import make_employee from hrms.hr.doctype.attendance.attendance import mark_attendance -from hrms.hr.doctype.holiday_list_assignment.test_holiday_list_assignment import assign_holiday_list +from hrms.hr.doctype.holiday_list_assignment.test_holiday_list_assignment import ( + assign_holiday_list, + create_holiday_list_assignment, +) from hrms.hr.doctype.leave_allocation.leave_allocation import OverlapError from hrms.hr.doctype.leave_application.test_leave_application import make_allocation_record from hrms.hr.doctype.shift_type.test_shift_type import setup_shift_type @@ -625,6 +628,105 @@ def test_attendance_with_department_and_branch_filter_combined(self): self.assertNotIn(emp_wrong_branch, employees_in_report) self.assertNotIn(emp_wrong_dept, employees_in_report) + def test_multiple_holiday_list_assignments_in_detailed_view(self): + """ + Employee switches holiday lists mid-month. + Holidays from each list must appear only within their effective date range. + + HL-1 active days 1-15: holiday on day 5 AND day 20 (day 20 should NOT appear) + HL-2 active days 16-end: holiday on day 10 AND day 25 (day 10 should NOT appear) + """ + previous_month_first = get_first_day_for_prev_month() + year_start = getdate(get_year_start(previous_month_first)) + year_end = getdate(get_year_ending(previous_month_first)) + + hl1_day = previous_month_first.replace(day=5) # in HL-1's range + hl1_bleed = previous_month_first.replace(day=20) # HL-1 holiday outside its range + hl2_bleed = previous_month_first.replace(day=10) # HL-2 holiday before it becomes active + hl2_day = previous_month_first.replace(day=25) # in HL-2's range + hl2_start = previous_month_first.replace(day=16) + + hl1 = make_holiday_list( + "Test Multi HL-1", from_date=year_start, to_date=year_end, add_weekly_offs=False + ) + hl2 = make_holiday_list( + "Test Multi HL-2", from_date=year_start, to_date=year_end, add_weekly_offs=False + ) + + add_holiday_to_list(hl1, hl1_day) + add_holiday_to_list(hl1, hl1_bleed) # outside effective range — must not show + add_holiday_to_list(hl2, hl2_bleed) # before HL-2 becomes active — must not show + add_holiday_to_list(hl2, hl2_day) + + frappe.db.delete("Holiday List Assignment", {"assigned_to": self.employee}) + create_holiday_list_assignment("Employee", self.employee, hl1, from_date=previous_month_first) + create_holiday_list_assignment("Employee", self.employee, hl2, from_date=hl2_start) + + mark_attendance(self.employee, previous_month_first, "Present") + + filters = frappe._dict( + { + "month": previous_month_first.month, + "year": previous_month_first.year, + "company": self.company, + "filter_based_on": "Month", + } + ) + report = execute(filters=filters) + row = report[1][0] + + # holiday in HL-1's active window → must appear + self.assertEqual(row[date_key(hl1_day)], "H") + # holiday in HL-2's active window → must appear + self.assertEqual(row[date_key(hl2_day)], "H") + # HL-1 holiday after HL-2 takes over → must NOT appear + self.assertNotEqual(row[date_key(hl1_bleed)], "H") + # HL-2 holiday before HL-2 becomes active → must NOT appear + self.assertNotEqual(row[date_key(hl2_bleed)], "H") + + def test_multiple_holiday_list_assignments_in_summarized_view(self): + """ + Total holiday count must combine holidays from all active holiday list assignments. + """ + previous_month_first = get_first_day_for_prev_month() + year_start = getdate(get_year_start(previous_month_first)) + year_end = getdate(get_year_ending(previous_month_first)) + + hl1_day = previous_month_first.replace(day=5) + hl2_day = previous_month_first.replace(day=25) + hl2_start = previous_month_first.replace(day=16) + + hl1 = make_holiday_list( + "Test Multi HL-1", from_date=year_start, to_date=year_end, add_weekly_offs=False + ) + hl2 = make_holiday_list( + "Test Multi HL-2", from_date=year_start, to_date=year_end, add_weekly_offs=False + ) + + add_holiday_to_list(hl1, hl1_day) + add_holiday_to_list(hl2, hl2_day) + + frappe.db.delete("Holiday List Assignment", {"assigned_to": self.employee}) + create_holiday_list_assignment("Employee", self.employee, hl1, from_date=previous_month_first) + create_holiday_list_assignment("Employee", self.employee, hl2, from_date=hl2_start) + + mark_attendance(self.employee, previous_month_first.replace(day=3), "Present") + + filters = frappe._dict( + { + "month": previous_month_first.month, + "year": previous_month_first.year, + "company": self.company, + "filter_based_on": "Month", + "summarized_view": 1, + } + ) + report = execute(filters=filters) + row = report[1][0] + + # one holiday from each list → total must be 2 + self.assertEqual(row["total_holidays"], 2) + def test_detailed_view_with_date_range_and_group_by_filter(self): today = getdate() mark_attendance(self.employee, today, "Absent", "Day Shift") @@ -701,3 +803,9 @@ def create_branch(branch_name): if not frappe.db.exists("Branch", branch_name): frappe.get_doc({"doctype": "Branch", "branch": branch_name}).insert(ignore_permissions=True) return branch_name + + +def add_holiday_to_list(holiday_list_name, holiday_date, description="Test Holiday"): + hl = frappe.get_doc("Holiday List", holiday_list_name) + hl.append("holidays", {"holiday_date": holiday_date, "description": description, "weekly_off": 0}) + hl.save() diff --git a/hrms/utils/holiday_list.py b/hrms/utils/holiday_list.py index 7f8680bb9f..4764255197 100644 --- a/hrms/utils/holiday_list.py +++ b/hrms/utils/holiday_list.py @@ -139,6 +139,109 @@ def get_assigned_holiday_list(assigned_to: str, as_on=None, as_dict: bool = Fals return holiday_list +def get_holiday_lists_for_employee_in_date_range( + employee: str, + start_date: date | str, + end_date: date | str, + as_dict: bool = True, +) -> list[dict]: + """ + Returns all applicable holiday lists for an employee within a date range. + Effective to_date = MIN(Holiday List's to_date, next assignment's from_date - 1 day + [ + {"holiday_list": "HL-1", "from_date": date, "to_date": date}, + {"holiday_list": "HL-2", "from_date": date, "to_date": date}, + ] + """ + start_date = getdate(start_date) + end_date = getdate(end_date) + + HLA = frappe.qb.DocType("Holiday List Assignment") + HolidayList = frappe.qb.DocType("Holiday List") + + assignments = ( + frappe.qb.from_(HLA) + .join(HolidayList) + .on(HLA.holiday_list == HolidayList.name) + .select( + HLA.holiday_list, + HLA.from_date, + HolidayList.to_date.as_("holiday_list_to_date"), + ) + .where(HLA.assigned_to == employee) + .where(HLA.docstatus == 1) + .where(HLA.from_date <= end_date) + .where(HolidayList.to_date >= start_date) + .orderby(HLA.from_date) + ).run(as_dict=True) + + if not assignments: + company = frappe.db.get_value("Employee", employee, "company") + if company: + assignments = get_holiday_lists_for_company_in_date_range(company, start_date, end_date) + + if not assignments: + return [] + + result = [] + for idx, assignment in enumerate(assignments): + from_date = assignment.from_date + hl_to_date = assignment.holiday_list_to_date + + next_assignment = assignments[idx + 1] if idx + 1 < len(assignments) else None + if next_assignment: + effective_to_date = min(hl_to_date, add_days(next_assignment.from_date, -1)) + else: + effective_to_date = hl_to_date + + from_date = max(from_date, start_date) + effective_to_date = min(effective_to_date, end_date) + + if from_date <= effective_to_date: + result.append( + { + "holiday_list": assignment.holiday_list, + "from_date": from_date, + "to_date": effective_to_date, + } + ) + + return result + + +def get_holiday_lists_for_company_in_date_range( + company: str, + start_date: date | str, + end_date: date | str, +) -> list[dict]: + """ + Returns all applicable holiday lists for a company within a date range. + """ + start_date = getdate(start_date) + end_date = getdate(end_date) + + HLA = frappe.qb.DocType("Holiday List Assignment") + HolidayList = frappe.qb.DocType("Holiday List") + + assignments = ( + frappe.qb.from_(HLA) + .join(HolidayList) + .on(HLA.holiday_list == HolidayList.name) + .select( + HLA.holiday_list, + HLA.from_date, + HolidayList.to_date.as_("holiday_list_to_date"), + ) + .where(HLA.assigned_to == company) + .where(HLA.docstatus == 1) + .where(HLA.from_date <= end_date) + .where(HolidayList.to_date >= start_date) + .orderby(HLA.from_date) + ).run(as_dict=True) + + return assignments + + def invalidate_cache(doc, method=None): from hrms.payroll.doctype.salary_slip.salary_slip import HOLIDAYS_BETWEEN_DATES From ad3b5fe7a31e17e91ba437600dd41f835d42db27 Mon Sep 17 00:00:00 2001 From: Krishna Shirsath Date: Mon, 20 Apr 2026 16:39:32 +0530 Subject: [PATCH 3/6] fix(monthly_attendance_sheet): refactor holiday list assignment logic for improved efficiency --- .../monthly_attendance_sheet.py | 134 +++++++------ hrms/utils/holiday_list.py | 176 +++++++++++------- 2 files changed, 184 insertions(+), 126 deletions(-) diff --git a/hrms/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py b/hrms/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py index 6b2130aaa0..96fb6868a4 100644 --- a/hrms/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py +++ b/hrms/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py @@ -13,11 +13,11 @@ from frappe import _ from frappe.query_builder import Case from frappe.query_builder.functions import Count, Extract, Sum -from frappe.utils import cint, cstr, formatdate, getdate +from frappe.utils import add_days, cint, cstr, formatdate, getdate from frappe.utils.nestedset import get_descendants_of from hrms.utils import date_diff, get_date_range -from hrms.utils.holiday_list import get_holiday_lists_for_employee_in_date_range +from hrms.utils.holiday_list import fill_date_gaps_with_fallback, get_holiday_lists_bulk Filters = frappe._dict @@ -236,7 +236,14 @@ def get_date_condition(docfield: Field, filters: Filters) -> Criterion: def get_data(filters: Filters, attendance_map: dict) -> list[dict]: employee_details, group_by_param_values = get_employee_related_details(filters) - holiday_map = get_holiday_map(filters) + + # flatten grouped structure so get_employee_holiday_map always gets {emp: details} + if filters.group_by: + flat_employees = {emp: det for group in employee_details.values() for emp, det in group.items()} + else: + flat_employees = employee_details + + employee_holiday_map = get_employee_holiday_map(flat_employees, filters) data = [] if filters.group_by: @@ -246,14 +253,14 @@ def get_data(filters: Filters, attendance_map: dict) -> list[dict]: if not value: continue - records = get_rows(employee_details[value], filters, holiday_map, attendance_map) + records = get_rows(employee_details[value], filters, employee_holiday_map, attendance_map) if records: data.append({group_by_column: value}) data.extend(records) else: - data = get_rows(employee_details, filters, holiday_map, attendance_map) + data = get_rows(employee_details, filters, employee_holiday_map, attendance_map) return data @@ -306,6 +313,7 @@ def get_attendance_map(filters: Filters) -> dict: def get_attendance_records(filters: Filters) -> list[dict]: Attendance = frappe.qb.DocType("Attendance") + Employee = frappe.qb.DocType("Employee") attendance_date_condition = get_date_condition(Attendance.attendance_date, filters) status = ( frappe.qb.terms.Case() @@ -336,6 +344,14 @@ def get_attendance_records(filters: Filters) -> list[dict]: if filters.employee: query = query.where(Attendance.employee == filters.employee) + + if filters.department or filters.branch: + query = query.join(Employee).on(Attendance.employee == Employee.name) + if filters.department and filters.department != "All Departments": + query = query.where(Employee.department == filters.department) + if filters.branch: + query = query.where(Employee.branch == filters.branch) + query = query.orderby(Attendance.employee, Attendance.attendance_date) return query.run(as_dict=1) @@ -405,69 +421,69 @@ def get_employee_related_details(filters: Filters) -> tuple[dict, list]: return emp_map, group_by_param_values -def get_holiday_map(filters: Filters) -> dict[str, list[dict]]: +def get_employee_holiday_map(employee_details: dict, filters: Filters) -> dict[str, list[dict]]: """ - Returns a dict of holidays falling in the filter month and year - with list name as key and list of holidays as values like - { - 'Holiday List 1': [ - {'day_of_month': '0' , 'weekly_off': 1}, - {'day_of_month': '1', 'weekly_off': 0} - ], - 'Holiday List 2': [ - {'day_of_month': '0' , 'weekly_off': 1}, - {'day_of_month': '1', 'weekly_off': 0} - ] - } - """ - # add default holiday list too - holiday_lists = frappe.db.get_all("Holiday List", pluck="name") - default_holiday_list = frappe.get_cached_value("Company", filters.company, "default_holiday_list") - holiday_lists.append(default_holiday_list) + Builds {employee: [holidays]} for all employees in two queries. - holiday_map = frappe._dict() - Holiday = frappe.qb.DocType("Holiday") - - holiday_condition = get_date_condition(Holiday.holiday_date, filters) + Query 1 — bulk HLA fetch for all employees + their companies. + Query 2 — holidays for only the holiday lists employees are actually assigned to. - for d in holiday_lists: - if not d: - continue + Per-employee lookup after this call is an O(1) dict access. + """ + if not employee_details: + return {} - holidays = ( - frappe.qb.from_(Holiday) - .select(Holiday.holiday_date, Holiday.weekly_off) - .where((Holiday.parent == d) & (holiday_condition)) - ).run(as_dict=True) - holiday_map.setdefault(d, holidays) + start_date, end_date = get_date_range_from_filters(filters) - return holiday_map + employees = list(employee_details.keys()) + companies = list({d.company for d in employee_details.values() if d.get("company")}) + # Query 1: one bulk HLA fetch for all employees + companies + bulk = get_holiday_lists_bulk(employees + companies, start_date, end_date) -def get_holidays_for_employee(employee: str, filters: Filters, holiday_map: dict) -> list[dict]: - """ - Returns the merged list of holidays for an employee within the filter period, - correctly handling multiple holiday list assignments. + # resolve each employee's effective HL ranges. + # gaps in employee-level assignments are filled with company-level assignments, + # so e.g. company WO/H entries before an employee's first HLA still appear. + employee_hl_ranges = {} + for employee, details in employee_details.items(): + employee_ranges = bulk.get(employee, []) + company_ranges = bulk.get(details.get("company"), []) + ranges = fill_date_gaps_with_fallback(employee_ranges, company_ranges, start_date, end_date) + if ranges: + employee_hl_ranges[employee] = ranges - For each assignment, only holidays that fall within the effective date range - of that assignment are included (from_date to effective to_date). - """ - start_date, end_date = get_date_range_from_filters(filters) + if not employee_hl_ranges: + return {} - hl_assignments = get_holiday_lists_for_employee_in_date_range(employee, start_date, end_date) + # collect only the HL names employees are actually assigned to + used_hl_names = {r["holiday_list"] for ranges in employee_hl_ranges.values() for r in ranges} - if not hl_assignments: - return [] + # Query 2: holidays for used HLs only, filtered to the period + Holiday = frappe.qb.DocType("Holiday") + holiday_rows = ( + frappe.qb.from_(Holiday) + .select(Holiday.parent, Holiday.holiday_date, Holiday.weekly_off) + .where(Holiday.parent.isin(list(used_hl_names))) + .where(Holiday.holiday_date.between(start_date, end_date)) + ).run(as_dict=True) - merged_holidays = [] - for assignment in hl_assignments: - hl_holidays = holiday_map.get(assignment["holiday_list"], []) - for holiday in hl_holidays: - h_date = getdate(holiday.holiday_date) - if assignment["from_date"] <= h_date <= assignment["to_date"]: - merged_holidays.append(holiday) + hl_holidays = {} + for h in holiday_rows: + hl_holidays.setdefault(h.parent, []).append(h) + + # filter holidays to each employee's effective ranges + employee_holiday_map = {} + for employee, ranges in employee_hl_ranges.items(): + holidays = [ + h + for r in ranges + for h in hl_holidays.get(r["holiday_list"], []) + if r["from_date"] <= getdate(h.holiday_date) <= r["to_date"] + ] + if holidays: + employee_holiday_map[employee] = holidays - return merged_holidays + return employee_holiday_map def get_date_range_from_filters(filters: Filters) -> tuple: @@ -480,10 +496,12 @@ def get_date_range_from_filters(filters: Filters) -> tuple: return getdate(filters.start_date), getdate(filters.end_date) -def get_rows(employee_details: dict, filters: Filters, holiday_map: dict, attendance_map: dict) -> list[dict]: +def get_rows( + employee_details: dict, filters: Filters, employee_holiday_map: dict, attendance_map: dict +) -> list[dict]: records = [] for employee, details in employee_details.items(): - holidays = get_holidays_for_employee(employee, filters, holiday_map) + holidays = employee_holiday_map.get(employee, []) if filters.summarized_view: attendance = get_attendance_status_for_summarized_view( diff --git a/hrms/utils/holiday_list.py b/hrms/utils/holiday_list.py index 4764255197..9cdf571fd6 100644 --- a/hrms/utils/holiday_list.py +++ b/hrms/utils/holiday_list.py @@ -139,107 +139,147 @@ def get_assigned_holiday_list(assigned_to: str, as_on=None, as_dict: bool = Fals return holiday_list -def get_holiday_lists_for_employee_in_date_range( - employee: str, +def get_holiday_lists_bulk( + assigned_to_list: list[str], start_date: date | str, end_date: date | str, - as_dict: bool = True, -) -> list[dict]: +) -> dict[str, list[dict]]: """ - Returns all applicable holiday lists for an employee within a date range. - Effective to_date = MIN(Holiday List's to_date, next assignment's from_date - 1 day - [ - {"holiday_list": "HL-1", "from_date": date, "to_date": date}, - {"holiday_list": "HL-2", "from_date": date, "to_date": date}, - ] + Returns effective holiday list ranges for multiple assigned_to values in one query. + + { + "EMP-001": [{"holiday_list": "HL-1", "from_date": date, "to_date": date}, ...], + "EMP-002": [...], + } """ + if not assigned_to_list: + return {} + start_date = getdate(start_date) end_date = getdate(end_date) + return get_holiday_list_assignments(assigned_to_list, start_date, end_date) + + +def get_holiday_list_assignments( + assigned_to_list: list[str], + start_date: date, + end_date: date, +) -> dict[str, list[dict]]: + """ + Single query: returns effective HLA ranges per assigned_to, clipped to start_date/end_date. + effective_to_date = MIN(HL.to_date, next assignment's from_date - 1 day) + + { + "EMP-001": [{"holiday_list": "HL-1", "from_date": date, "to_date": date}, ...], + } + """ HLA = frappe.qb.DocType("Holiday List Assignment") HolidayList = frappe.qb.DocType("Holiday List") - assignments = ( + rows = ( frappe.qb.from_(HLA) .join(HolidayList) .on(HLA.holiday_list == HolidayList.name) .select( + HLA.assigned_to, HLA.holiday_list, HLA.from_date, HolidayList.to_date.as_("holiday_list_to_date"), ) - .where(HLA.assigned_to == employee) + .where(HLA.assigned_to.isin(assigned_to_list)) .where(HLA.docstatus == 1) .where(HLA.from_date <= end_date) .where(HolidayList.to_date >= start_date) + .orderby(HLA.assigned_to) .orderby(HLA.from_date) ).run(as_dict=True) - if not assignments: - company = frappe.db.get_value("Employee", employee, "company") - if company: - assignments = get_holiday_lists_for_company_in_date_range(company, start_date, end_date) - - if not assignments: - return [] - - result = [] - for idx, assignment in enumerate(assignments): - from_date = assignment.from_date - hl_to_date = assignment.holiday_list_to_date - - next_assignment = assignments[idx + 1] if idx + 1 < len(assignments) else None - if next_assignment: - effective_to_date = min(hl_to_date, add_days(next_assignment.from_date, -1)) - else: - effective_to_date = hl_to_date - - from_date = max(from_date, start_date) - effective_to_date = min(effective_to_date, end_date) - - if from_date <= effective_to_date: - result.append( - { - "holiday_list": assignment.holiday_list, - "from_date": from_date, - "to_date": effective_to_date, - } - ) + raw = {} + for row in rows: + raw.setdefault(row.assigned_to, []).append(row) + + result = {} + for assigned_to, assignments in raw.items(): + ranges = [] + for idx, assignment in enumerate(assignments): + hl_to_date = getdate(assignment.holiday_list_to_date) + next_assignment = assignments[idx + 1] if idx + 1 < len(assignments) else None + + if next_assignment: + effective_to_date = min(hl_to_date, add_days(next_assignment.from_date, -1)) + else: + effective_to_date = hl_to_date + + from_date = max(getdate(assignment.from_date), start_date) + effective_to_date = min(getdate(effective_to_date), end_date) + + if from_date <= effective_to_date: + ranges.append( + { + "holiday_list": assignment.holiday_list, + "from_date": from_date, + "to_date": effective_to_date, + } + ) + if ranges: + result[assigned_to] = ranges return result -def get_holiday_lists_for_company_in_date_range( - company: str, - start_date: date | str, - end_date: date | str, +def fill_date_gaps_with_fallback( + primary_ranges: list[dict], + fallback_ranges: list[dict], + start_date: date, + end_date: date, ) -> list[dict]: """ - Returns all applicable holiday lists for a company within a date range. - """ - start_date = getdate(start_date) - end_date = getdate(end_date) + For any dates in [start_date, end_date] not covered by primary_ranges, + fills those gaps using fallback_ranges (typically company assignments). - HLA = frappe.qb.DocType("Holiday List Assignment") - HolidayList = frappe.qb.DocType("Holiday List") + Example: employee HLA starts Jan 16, company HLA covers full month → + Jan 1-15 use the company holiday list, Jan 16-31 use the employee's. + """ + if not primary_ranges: + return fallback_ranges + if not fallback_ranges: + return primary_ranges - assignments = ( - frappe.qb.from_(HLA) - .join(HolidayList) - .on(HLA.holiday_list == HolidayList.name) - .select( - HLA.holiday_list, - HLA.from_date, - HolidayList.to_date.as_("holiday_list_to_date"), - ) - .where(HLA.assigned_to == company) - .where(HLA.docstatus == 1) - .where(HLA.from_date <= end_date) - .where(HolidayList.to_date >= start_date) - .orderby(HLA.from_date) - ).run(as_dict=True) + result = [] + current = start_date + + for primary in sorted(primary_ranges, key=lambda r: r["from_date"]): + gap_end = add_days(primary["from_date"], -1) + if current <= gap_end: + for fallback in fallback_ranges: + overlap_start = max(getdate(fallback["from_date"]), current) + overlap_end = min(getdate(fallback["to_date"]), gap_end) + if overlap_start <= overlap_end: + result.append( + { + "holiday_list": fallback["holiday_list"], + "from_date": overlap_start, + "to_date": overlap_end, + } + ) + result.append(primary) + current = add_days(primary["to_date"], 1) + + if current <= end_date: + for fallback in fallback_ranges: + overlap_start = max(getdate(fallback["from_date"]), current) + overlap_end = min(getdate(fallback["to_date"]), end_date) + if overlap_start <= overlap_end: + result.append( + { + "holiday_list": fallback["holiday_list"], + "from_date": overlap_start, + "to_date": overlap_end, + } + ) - return assignments + return sorted(result, key=lambda r: r["from_date"]) def invalidate_cache(doc, method=None): From fa14f04f4951e97e8fcb668602f82f8d60f6422c Mon Sep 17 00:00:00 2001 From: Krishna Pramod Shirsath <91021227+krishna-254@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:43:16 +0530 Subject: [PATCH 4/6] fix(monthly_attendance_sheet): rename variables to full names for clarity Co-authored-by: Asmita Hase <44727809+asmitahase@users.noreply.github.com> --- .../monthly_attendance_sheet/monthly_attendance_sheet.py | 8 +++++--- hrms/utils/holiday_list.py | 8 ++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/hrms/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py b/hrms/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py index 96fb6868a4..1a602dee09 100644 --- a/hrms/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py +++ b/hrms/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py @@ -239,11 +239,13 @@ def get_data(filters: Filters, attendance_map: dict) -> list[dict]: # flatten grouped structure so get_employee_holiday_map always gets {emp: details} if filters.group_by: - flat_employees = {emp: det for group in employee_details.values() for emp, det in group.items()} + ungrouped_employee_details = {} + for details in employee_details.values(): + ungrouped_employee_details.update(details) else: - flat_employees = employee_details + ungrouped_employee_details = employee_details - employee_holiday_map = get_employee_holiday_map(flat_employees, filters) + employee_holiday_map = get_employee_holiday_map(ungrouped_employee_details, filters) data = [] if filters.group_by: diff --git a/hrms/utils/holiday_list.py b/hrms/utils/holiday_list.py index 9cdf571fd6..dde9df311f 100644 --- a/hrms/utils/holiday_list.py +++ b/hrms/utils/holiday_list.py @@ -177,7 +177,7 @@ def get_holiday_list_assignments( HLA = frappe.qb.DocType("Holiday List Assignment") HolidayList = frappe.qb.DocType("Holiday List") - rows = ( + holiday_list_assignments = ( frappe.qb.from_(HLA) .join(HolidayList) .on(HLA.holiday_list == HolidayList.name) @@ -195,9 +195,9 @@ def get_holiday_list_assignments( .orderby(HLA.from_date) ).run(as_dict=True) - raw = {} - for row in rows: - raw.setdefault(row.assigned_to, []).append(row) + holiday_assignment_map = {} + for assignment in holiday_list_assignments: + holiday_assignment_map.setdefault(assignment.assigned_to, []).append(assignment) result = {} for assigned_to, assignments in raw.items(): From 34f66cf905b63e61af8666d70a413fda482441b2 Mon Sep 17 00:00:00 2001 From: Krishna Shirsath Date: Mon, 27 Apr 2026 13:59:02 +0530 Subject: [PATCH 5/6] fix(monthly_attendance_sheet): update holiday list assignment functions for better clarity. --- .../monthly_attendance_sheet.py | 23 ++++++++------- hrms/utils/holiday_list.py | 28 ++++++++++++++----- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/hrms/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py b/hrms/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py index 1a602dee09..e7a41d28c1 100644 --- a/hrms/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py +++ b/hrms/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py @@ -17,7 +17,10 @@ from frappe.utils.nestedset import get_descendants_of from hrms.utils import date_diff, get_date_range -from hrms.utils.holiday_list import fill_date_gaps_with_fallback, get_holiday_lists_bulk +from hrms.utils.holiday_list import ( + fill_employee_holiday_list_date_gaps_with_company_holiday_list, + get_assigned_holiday_lists_to_employee_and_company, +) Filters = frappe._dict @@ -241,7 +244,7 @@ def get_data(filters: Filters, attendance_map: dict) -> list[dict]: if filters.group_by: ungrouped_employee_details = {} for details in employee_details.values(): - ungrouped_employee_details.update(details) + ungrouped_employee_details.update(details) else: ungrouped_employee_details = employee_details @@ -440,17 +443,18 @@ def get_employee_holiday_map(employee_details: dict, filters: Filters) -> dict[s employees = list(employee_details.keys()) companies = list({d.company for d in employee_details.values() if d.get("company")}) - # Query 1: one bulk HLA fetch for all employees + companies - bulk = get_holiday_lists_bulk(employees + companies, start_date, end_date) + assigned_holiday_lists = get_assigned_holiday_lists_to_employee_and_company( + employees + companies, start_date, end_date + ) - # resolve each employee's effective HL ranges. # gaps in employee-level assignments are filled with company-level assignments, - # so e.g. company WO/H entries before an employee's first HLA still appear. employee_hl_ranges = {} for employee, details in employee_details.items(): - employee_ranges = bulk.get(employee, []) - company_ranges = bulk.get(details.get("company"), []) - ranges = fill_date_gaps_with_fallback(employee_ranges, company_ranges, start_date, end_date) + employee_ranges = assigned_holiday_lists.get(employee, []) + company_ranges = assigned_holiday_lists.get(details.get("company"), []) + ranges = fill_employee_holiday_list_date_gaps_with_company_holiday_list( + employee_ranges, company_ranges, start_date, end_date + ) if ranges: employee_hl_ranges[employee] = ranges @@ -460,7 +464,6 @@ def get_employee_holiday_map(employee_details: dict, filters: Filters) -> dict[s # collect only the HL names employees are actually assigned to used_hl_names = {r["holiday_list"] for ranges in employee_hl_ranges.values() for r in ranges} - # Query 2: holidays for used HLs only, filtered to the period Holiday = frappe.qb.DocType("Holiday") holiday_rows = ( frappe.qb.from_(Holiday) diff --git a/hrms/utils/holiday_list.py b/hrms/utils/holiday_list.py index dde9df311f..df497be4b3 100644 --- a/hrms/utils/holiday_list.py +++ b/hrms/utils/holiday_list.py @@ -139,13 +139,13 @@ def get_assigned_holiday_list(assigned_to: str, as_on=None, as_dict: bool = Fals return holiday_list -def get_holiday_lists_bulk( +def get_assigned_holiday_lists_to_employee_and_company( assigned_to_list: list[str], start_date: date | str, end_date: date | str, ) -> dict[str, list[dict]]: """ - Returns effective holiday list ranges for multiple assigned_to values in one query. + Returns effective holiday list ranges for multiple assigned_to values (employees/companies) in one query. { "EMP-001": [{"holiday_list": "HL-1", "from_date": date, "to_date": date}, ...], @@ -158,16 +158,16 @@ def get_holiday_lists_bulk( start_date = getdate(start_date) end_date = getdate(end_date) - return get_holiday_list_assignments(assigned_to_list, start_date, end_date) + return build_holiday_list_map(assigned_to_list, start_date, end_date) -def get_holiday_list_assignments( +def build_holiday_list_map( assigned_to_list: list[str], start_date: date, end_date: date, ) -> dict[str, list[dict]]: """ - Single query: returns effective HLA ranges per assigned_to, clipped to start_date/end_date. + Single query + compute effective HLA ranges per assigned_to, clipped to start_date/end_date. effective_to_date = MIN(HL.to_date, next assignment's from_date - 1 day) { @@ -199,8 +199,22 @@ def get_holiday_list_assignments( for assignment in holiday_list_assignments: holiday_assignment_map.setdefault(assignment.assigned_to, []).append(assignment) + result = build_effective_date_ranges_for_holiday_assignments(holiday_assignment_map, start_date, end_date) + + return result + + +def build_effective_date_ranges_for_holiday_assignments( + holiday_assignment_map: dict[str, list[dict]], + start_date: date, + end_date: date, +) -> dict[str, list[dict]]: + """ + Returns map of {assigned_to: [raw_holiday_list_assignment_rows]}, + effective_to_date = MIN(HL.to_date, next assignment's from_date - 1 day) + """ result = {} - for assigned_to, assignments in raw.items(): + for assigned_to, assignments in holiday_assignment_map.items(): ranges = [] for idx, assignment in enumerate(assignments): hl_to_date = getdate(assignment.holiday_list_to_date) @@ -228,7 +242,7 @@ def get_holiday_list_assignments( return result -def fill_date_gaps_with_fallback( +def fill_employee_holiday_list_date_gaps_with_company_holiday_list( primary_ranges: list[dict], fallback_ranges: list[dict], start_date: date, From a008840c8f3a8a3100395e092a068db8fbd283db Mon Sep 17 00:00:00 2001 From: MochaMind Date: Tue, 28 Apr 2026 12:53:44 +0530 Subject: [PATCH 6/6] fix: sync translations from crowdin --- hrms/locale/sv.po | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/hrms/locale/sv.po b/hrms/locale/sv.po index 0ec9e28b59..c8e2cfa9c7 100644 --- a/hrms/locale/sv.po +++ b/hrms/locale/sv.po @@ -3,7 +3,7 @@ msgstr "" "Project-Id-Version: frappe\n" "Report-Msgid-Bugs-To: contact@frappe.io\n" "POT-Creation-Date: 2026-04-26 09:47+0000\n" -"PO-Revision-Date: 2026-04-26 18:15\n" +"PO-Revision-Date: 2026-04-27 18:20\n" "Last-Translator: contact@frappe.io\n" "Language-Team: Swedish\n" "MIME-Version: 1.0\n" @@ -3241,7 +3241,7 @@ msgstr "Referens" #: hrms/hr/doctype/leave_application/leave_application_calendar.js:487 msgid "Employee Required" -msgstr "" +msgstr "Personal Erfordras" #. Label of the employee_responsible (Link) field in DocType 'Employee #. Grievance' @@ -4755,7 +4755,7 @@ msgstr "Halv Dag Datum" #: hrms/hr/doctype/leave_application/leave_application.py:916 msgid "Half Day Date cannot be a holiday" -msgstr "" +msgstr "Halvdag Datum får inte vara en helgdag" #: hrms/hr/doctype/compensatory_leave_request/compensatory_leave_request.py:48 msgid "Half Day Date is mandatory" @@ -5936,7 +5936,7 @@ msgstr "Frånvaro Godkännare Erfordras i Frånvaro Ansökan" #: hrms/hr/doctype/leave_application/leave_application_calendar.js:493 msgid "Leave Approver Missing" -msgstr "" +msgstr "Frånvaro Godkännare Saknas" #. Label of the leave_approver_name (Data) field in DocType 'Leave Application' #: hrms/hr/doctype/leave_application/leave_application.json @@ -7310,7 +7310,7 @@ msgstr "Öppna Återkoppling" #: hrms/hr/doctype/leave_application/leave_application_calendar.js:417 msgid "Open Full Form" -msgstr "" +msgstr "Öppna i Full Formulär" #: hrms/hr/doctype/leave_application/leave_application_email_template.html:30 msgid "Open Now" @@ -7981,7 +7981,7 @@ msgstr "Välj Sökande" #: hrms/hr/doctype/leave_application/leave_application_calendar.js:488 msgid "Please select an Employee before continuing." -msgstr "" +msgstr "Välj Personal innan du fortsätter." #: hrms/hr/doctype/shift_assignment_tool/shift_assignment_tool.py:287 msgid "Please select at least one Shift Request to perform this action." @@ -8046,7 +8046,7 @@ msgstr "Ange Inkomst Komponent för Frånvaro Typ: {0}." #: hrms/hr/doctype/leave_application/leave_application_calendar.js:494 msgid "Please set Leave Approver for the Employee: {0}" -msgstr "" +msgstr "Ange Frånvaro Godkännande för: {0}" #: hrms/payroll/doctype/salary_slip/salary_slip.py:580 msgid "Please set Payroll based on in Payroll settings" @@ -8308,7 +8308,7 @@ msgstr "Egenskap är redan tillagd" #: hrms/hr/doctype/leave_application/leave_application_calendar.js:51 msgid "Provide a short reason for leave." -msgstr "" +msgstr "Ange kort anledning för frånvaro." #. Name of a report #. Label of a Link in the Payroll Workspace @@ -10999,7 +10999,7 @@ msgstr "Typ av Verifikat" #: hrms/hr/doctype/leave_application/leave_application_calendar.js:349 msgid "Unable to load leave allocation details right now." -msgstr "" +msgstr "Det går inte att ladda information för frånvaro tilldelning just nu." #: hrms/public/js/utils/index.js:208 msgid "Unable to retrieve your location"