From 07ea63036f579f23ae8f460898c116b4513d4f2b Mon Sep 17 00:00:00 2001 From: iamkhanraheel Date: Fri, 3 Jul 2026 23:04:27 +0530 Subject: [PATCH 1/7] fix(leave_policy_assignment): correct half-yearly earned leave schedule for joining date based assignment --- .../leave_policy_assignment.py | 49 ++++++++++++++----- hrms/hr/utils.py | 25 ++++------ 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/hrms/hr/doctype/leave_policy_assignment/leave_policy_assignment.py b/hrms/hr/doctype/leave_policy_assignment/leave_policy_assignment.py index acdc0cf670..764c144218 100644 --- a/hrms/hr/doctype/leave_policy_assignment/leave_policy_assignment.py +++ b/hrms/hr/doctype/leave_policy_assignment/leave_policy_assignment.py @@ -253,12 +253,19 @@ def get_periods_passed(self, earned_leave_frequency, current_date, from_date, co "Yearly": (1, 12), }.get(earned_leave_frequency) - periods_passed = calculate_periods_passed( - current_date, from_date, periods_per_year, months_per_period, consider_current_period + effective_from = self.effective_from if earned_leave_frequency == "Half-Yearly" else None + return max( + calculate_periods_passed( + current_date, + from_date, + periods_per_year, + months_per_period, + consider_current_period, + effective_from=effective_from, + ), + 0, ) - return periods_passed - def calculate_leaves_for_passed_period( self, annual_allocation, leave_details, date_of_joining, periods_passed, consider_current_period ): @@ -301,6 +308,7 @@ def get_earned_leave_schedule( self, annual_allocation, leave_details, date_of_joining, new_leaves_allocated ): from hrms.hr.utils import ( + get_complete_month_count, get_expected_allocation_date_for_period, get_monthly_earned_leave, get_sub_period_start_and_end, @@ -326,7 +334,16 @@ def get_earned_leave_schedule( date_of_joining, effective_from=self.effective_from, ) + if ( + leave_details.earned_leave_frequency == "Half-Yearly" + and self.assignment_based_on == "Joining Date" + and to_date >= add_months(from_date, 12) + ): + max_allocations = get_complete_month_count(to_date, from_date) // months_to_add + else: + max_allocations = 0 schedule = [] + allocations_added = 0 if new_leaves_allocated: schedule.append( { @@ -337,6 +354,7 @@ def get_earned_leave_schedule( "attempted": 1, } ) + allocations_added += 1 last_allocated_date = get_sub_period_start_and_end( today, leave_details.earned_leave_frequency, @@ -354,6 +372,9 @@ def get_earned_leave_schedule( "attempted": 1 if date_already_passed else 0, } schedule.append(row) + allocations_added += 1 + if max_allocations and allocations_added >= max_allocations: + break date = get_expected_allocation_date_for_period( leave_details.earned_leave_frequency, leave_details.allocate_on_day, @@ -363,7 +384,7 @@ def get_earned_leave_schedule( ) if from_date < getdate(date_of_joining): pro_rated_period_start, pro_rated_period_end = get_sub_period_start_and_end( - date_of_joining, leave_details.earned_leave_frequency + date_of_joining, leave_details.earned_leave_frequency, effective_from=self.effective_from ) pro_rated_earned_leave = get_monthly_earned_leave( date_of_joining, @@ -391,12 +412,19 @@ def get_pro_rata_period_end_date(consider_current_month): def calculate_periods_passed( - current_date, from_date, periods_per_year, months_per_period, consider_current_period + current_date, from_date, periods_per_year, months_per_period, consider_current_period, effective_from=None ): - periods_passed = 0 + if effective_from: + from hrms.hr.utils import get_complete_month_count - from_period = (from_date.year * periods_per_year) + ((from_date.month - 1) // months_per_period) - current_period = (current_date.year * periods_per_year) + ((current_date.month - 1) // months_per_period) + effective_from = getdate(effective_from) + from_period = get_complete_month_count(from_date, effective_from) // months_per_period + current_period = get_complete_month_count(current_date, effective_from) // months_per_period + else: + from_period = (from_date.year * periods_per_year) + ((from_date.month - 1) // months_per_period) + current_period = (current_date.year * periods_per_year) + ( + (current_date.month - 1) // months_per_period + ) periods_passed = current_period - from_period if consider_current_period: @@ -410,8 +438,7 @@ def is_earned_leave_applicable_for_current_period( ): date = getdate(frappe.flags.current_date) or getdate() - # For Half-Yearly, compute preiod relative to effective_from - # instead of calendar year + # For Half-Yearly, compute period relative to effective_from instead of calendar year if earned_leave_frequency == "Half-Yearly" and effective_from: from hrms.hr.utils import get_half_year_periods diff --git a/hrms/hr/utils.py b/hrms/hr/utils.py index cdd6f68df4..f4f52ec9a1 100644 --- a/hrms/hr/utils.py +++ b/hrms/hr/utils.py @@ -1074,27 +1074,22 @@ def get_semester_end(date): return add_months(get_year_ending(date), -6) -def get_half_year_periods(date, effective_from): - """ - Compute the half-year period relative to effective_from, - NOT relative to the calendar year. +def get_complete_month_count(date, effective_from): + """Returns count of complete months from effective_from to date, accounting for day-of-month.""" + month_count = (date.year - effective_from.year) * 12 + (date.month - effective_from.month) + if date.day < effective_from.day: + month_count -= 1 + return month_count - Example: - effective_from = 01-Apr-2026 - date = 01-Apr-2026 - → period_start = 01-Apr-2026, period_end = 30-Sep-2026 - date = 01-Oct-2026 - → period_start = 01-Oct-2026, period_end = 31-Mar-2027 - """ +def get_half_year_periods(date, effective_from): + """Return (start, end) of the half-year period containing date, relative to effective_from.""" effective_from = getdate(effective_from) date = getdate(date) - # Find how many full 6-month periods have elapsed since effective_from - months_elapsed = (date.year - effective_from.year) * 12 + (date.month - effective_from.month) - periods_elapsed = months_elapsed // 6 + half_years_passed = get_complete_month_count(date, effective_from) // 6 - half_year_start = add_months(effective_from, periods_elapsed * 6) + half_year_start = add_months(effective_from, half_years_passed * 6) half_year_end = add_days(add_months(half_year_start, 6), -1) return half_year_start, half_year_end From 135dc8cf295c396d2cf71d0616a983c3cf117f07 Mon Sep 17 00:00:00 2001 From: iamkhanraheel Date: Sun, 5 Jul 2026 23:09:25 +0530 Subject: [PATCH 2/7] fix(leave_policy_assignment): correct half-yearly max_allocations undercount --- .../doctype/leave_policy_assignment/leave_policy_assignment.py | 2 +- hrms/hr/utils.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/hrms/hr/doctype/leave_policy_assignment/leave_policy_assignment.py b/hrms/hr/doctype/leave_policy_assignment/leave_policy_assignment.py index 764c144218..a12f1c5dce 100644 --- a/hrms/hr/doctype/leave_policy_assignment/leave_policy_assignment.py +++ b/hrms/hr/doctype/leave_policy_assignment/leave_policy_assignment.py @@ -339,7 +339,7 @@ def get_earned_leave_schedule( and self.assignment_based_on == "Joining Date" and to_date >= add_months(from_date, 12) ): - max_allocations = get_complete_month_count(to_date, from_date) // months_to_add + max_allocations = get_complete_month_count(to_date, from_date) // months_to_add + 1 else: max_allocations = 0 schedule = [] diff --git a/hrms/hr/utils.py b/hrms/hr/utils.py index f4f52ec9a1..bf34b52622 100644 --- a/hrms/hr/utils.py +++ b/hrms/hr/utils.py @@ -1077,7 +1077,8 @@ def get_semester_end(date): def get_complete_month_count(date, effective_from): """Returns count of complete months from effective_from to date, accounting for day-of-month.""" month_count = (date.year - effective_from.year) * 12 + (date.month - effective_from.month) - if date.day < effective_from.day: + # ignore a smaller day caused by a shorter month (e.g. 31st -> 28th) + if date.day < effective_from.day and date != get_last_day(date): month_count -= 1 return month_count From 4ba05c334cf7dd18270eda34df46c1c8247e1f08 Mon Sep 17 00:00:00 2001 From: Daniel Radl Date: Fri, 10 Jul 2026 17:17:14 +0000 Subject: [PATCH 3/7] fix(pwa): skip push notification config fetch without relay --- frontend/src/main.js | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/frontend/src/main.js b/frontend/src/main.js index 035454f2a6..25b65b38b0 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -65,13 +65,15 @@ const registerServiceWorker = async () => { let serviceWorkerURL = "/assets/hrms/frontend/sw.js" let config = "" - try { - config = await window.frappePushNotification.fetchWebConfig() - serviceWorkerURL = `${serviceWorkerURL}?config=${encodeURIComponent( - JSON.stringify(config) - )}` - } catch (err) { - console.error("Failed to fetch FCM config", err) + if (window.frappe?.boot?.push_relay_server_url) { + try { + config = await window.frappePushNotification.fetchWebConfig() + serviceWorkerURL = `${serviceWorkerURL}?config=${encodeURIComponent( + JSON.stringify(config) + )}` + } catch (err) { + console.error("Failed to fetch FCM config", err) + } } navigator.serviceWorker From 3faae9be13f2c1236ba5cc1567d06dde0ff304aa Mon Sep 17 00:00:00 2001 From: pratheep-bit Date: Sun, 12 Jul 2026 20:46:29 +0530 Subject: [PATCH 4/7] fix: cancelled Shift Assignments silently block new shift creation via Roster API Two root causes found and fixed: 1. **on_cancel does not update status field**: When a Shift Assignment is cancelled, docstatus changes to 2 (framework-level), but the status field incorrectly remains 'Active'. This causes the cancelled record to match the adjacency filters in insert_shift(), which filter by status='Active'. Fix: Set status to 'Inactive' in on_cancel via db_set. 'Inactive' is an existing recognized status value in this doctype (used at line 99). 2. **insert_shift() lacks docstatus filter**: The adjacency detection at line 224 uses frappe.db.exists() without filtering out cancelled records (docstatus=2). Even with Fix 1, this is needed as defense-in-depth since frappe.db.exists() does not implicitly filter by docstatus. Fix: Add 'docstatus': ['!=', 2] to the filters dict. **Impact**: insert_shift() is @frappe.whitelist() decorated and called directly by ShiftAssignmentDialog.vue in the Roster view. When a user cancels a shift and creates a new one for an adjacent date range: - The cancelled record's end_date is silently mutated via db.set_value - No new assignment is created (the else branch is never reached) - The frontend shows 'Shift Assignment created successfully!' toast **Reproduction**: Create a shift (Jul 1-10), submit, cancel. Call insert_shift for Jul 11-20. Before fix: cancelled record's end_date changes from Jul 10 to Jul 20, zero new records created. After fix: cancelled record untouched, new submitted assignment created correctly. Includes regression test in test_roster.py. --- hrms/api/roster.py | 1 + .../shift_assignment/shift_assignment.py | 1 + hrms/tests/test_roster.py | 54 ++++++++++++++++++- 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/hrms/api/roster.py b/hrms/api/roster.py index 3b3f40e978..bc448e0d17 100644 --- a/hrms/api/roster.py +++ b/hrms/api/roster.py @@ -228,6 +228,7 @@ def insert_shift( "shift_type": shift_type, "status": status, "shift_location": shift_location, + "docstatus": ["!=", 2], } prev_shift = frappe.db.exists(dict({"end_date": add_days(start_date, -1)}, **filters)) next_shift = ( diff --git a/hrms/hr/doctype/shift_assignment/shift_assignment.py b/hrms/hr/doctype/shift_assignment/shift_assignment.py index 71eb7f3639..615bd50183 100644 --- a/hrms/hr/doctype/shift_assignment/shift_assignment.py +++ b/hrms/hr/doctype/shift_assignment/shift_assignment.py @@ -60,6 +60,7 @@ def on_update_after_submit(self): def on_cancel(self): self.validate_employee_checkin() self.validate_attendance() + self.db_set("status", "Inactive", update_modified=False) def validate_employee_checkin(self): checkins = frappe.get_all( diff --git a/hrms/tests/test_roster.py b/hrms/tests/test_roster.py index e1e2b30f33..ca46126a7b 100644 --- a/hrms/tests/test_roster.py +++ b/hrms/tests/test_roster.py @@ -1,7 +1,7 @@ import frappe from frappe.utils import add_days, getdate -from hrms.api.roster import get_shifts +from hrms.api.roster import get_shifts, insert_shift from hrms.tests.utils import HRMSTestSuite @@ -26,6 +26,58 @@ def test_get_shifts_returns_shift_type_details(self): self.assertEqual(str(shifts[employee.name][0]["end_time"]), "18:00:00") self.assertEqual(shifts[employee.name][0]["color"], "Violet") + def test_insert_shift_ignores_cancelled_assignment(self): + """Cancelled Shift Assignments should not block or be mutated by insert_shift.""" + employee_name = f"_Test Roster Cancel {frappe.generate_hash(length=8)}" + employee = create_employee(employee_name) + shift_type = create_shift_type( + f"_Test Cancel Shift {frappe.generate_hash(length=8)}", + start_time="08:00:00", + end_time="17:00:00", + color="Red", + ) + + # Create a shift assignment with a date range, submit it, then cancel it + original = frappe.get_doc({ + "doctype": "Shift Assignment", + "employee": employee.name, + "company": "_Test Company", + "shift_type": shift_type.name, + "start_date": "2026-07-01", + "end_date": "2026-07-10", + }) + original.submit() + original.cancel() + original.reload() + self.assertEqual(original.docstatus, 2) + original_end_date = original.end_date + + # Attempt to insert an adjacent shift (Jul 11 = day after Jul 10) + insert_shift( + employee=employee.name, + company="_Test Company", + shift_type=shift_type.name, + start_date="2026-07-11", + end_date="2026-07-20", + status="Active", + ) + + # The cancelled record must remain untouched + original.reload() + self.assertEqual(original.end_date, original_end_date) + + # A new, non-cancelled assignment must exist for the new range + new_assignment = frappe.db.exists( + "Shift Assignment", + { + "employee": employee.name, + "start_date": "2026-07-11", + "end_date": "2026-07-20", + "docstatus": ["!=", 2], + }, + ) + self.assertTrue(new_assignment) + def create_employee(employee_name: str): create_company() From 2d4022ffd1aea3a340f12fd995b08bc207e24c98 Mon Sep 17 00:00:00 2001 From: pratheep-bit Date: Tue, 14 Jul 2026 15:41:48 +0530 Subject: [PATCH 5/7] style: fix pre-commit linter issues --- hrms/tests/test_roster.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/hrms/tests/test_roster.py b/hrms/tests/test_roster.py index ca46126a7b..ecaace740b 100644 --- a/hrms/tests/test_roster.py +++ b/hrms/tests/test_roster.py @@ -38,14 +38,16 @@ def test_insert_shift_ignores_cancelled_assignment(self): ) # Create a shift assignment with a date range, submit it, then cancel it - original = frappe.get_doc({ - "doctype": "Shift Assignment", - "employee": employee.name, - "company": "_Test Company", - "shift_type": shift_type.name, - "start_date": "2026-07-01", - "end_date": "2026-07-10", - }) + original = frappe.get_doc( + { + "doctype": "Shift Assignment", + "employee": employee.name, + "company": "_Test Company", + "shift_type": shift_type.name, + "start_date": "2026-07-01", + "end_date": "2026-07-10", + } + ) original.submit() original.cancel() original.reload() From d770014fa3826a830c3224637f5d33bf52d59bab Mon Sep 17 00:00:00 2001 From: pratheep-bit Date: Tue, 14 Jul 2026 20:16:39 +0530 Subject: [PATCH 6/7] refactor: use query builder for leave management date queries - Resolves MariaDB 12.3 reserved word issue (to_date/from_date) by leveraging Frappe Query Builder for automatic identifier quoting. - Replaces raw frappe.db.sql calls with frappe.qb in: - utils.py (get_leave_period) - leave_allocation.py (validate_allocation_overlap) - leave_application.py (get_leave_entries) - leave_ledger_entry.py (validate_leave_allocation_against_leave_application) - Adds backticks to the remaining raw SQL snippet (get_doc_condition) and complex NOT EXISTS subquery (expire_allocation) where query builder translation is not feasible/clean. --- .../leave_allocation/leave_allocation.py | 24 +++++----- .../leave_application/leave_application.py | 44 ++++++++++++------- .../leave_ledger_entry/leave_ledger_entry.py | 29 ++++++------ hrms/hr/utils.py | 32 +++++++------- 4 files changed, 71 insertions(+), 58 deletions(-) diff --git a/hrms/hr/doctype/leave_allocation/leave_allocation.py b/hrms/hr/doctype/leave_allocation/leave_allocation.py index 89bb02d7b1..89f01cdf6c 100755 --- a/hrms/hr/doctype/leave_allocation/leave_allocation.py +++ b/hrms/hr/doctype/leave_allocation/leave_allocation.py @@ -220,17 +220,19 @@ def validate_lwp(self): ) def validate_allocation_overlap(self): - leave_allocation = frappe.db.sql( - """ - SELECT - name - FROM `tabLeave Allocation` - WHERE - employee=%s AND leave_type=%s - AND name <> %s AND docstatus=1 - AND to_date >= %s AND from_date <= %s""", - (self.employee, self.leave_type, self.name, self.from_date, self.to_date), - ) + LeaveAllocation = frappe.qb.DocType("Leave Allocation") + leave_allocation = ( + frappe.qb.from_(LeaveAllocation) + .select(LeaveAllocation.name) + .where( + (LeaveAllocation.employee == self.employee) + & (LeaveAllocation.leave_type == self.leave_type) + & (LeaveAllocation.name != self.name) + & (LeaveAllocation.docstatus == 1) + & (LeaveAllocation.to_date >= self.from_date) + & (LeaveAllocation.from_date <= self.to_date) + ) + ).run() if leave_allocation: frappe.msgprint( diff --git a/hrms/hr/doctype/leave_application/leave_application.py b/hrms/hr/doctype/leave_application/leave_application.py index 92bece1cd3..afa3bfc010 100755 --- a/hrms/hr/doctype/leave_application/leave_application.py +++ b/hrms/hr/doctype/leave_application/leave_application.py @@ -1318,23 +1318,33 @@ def get_leaves_for_period( def get_leave_entries(employee, leave_type, from_date, to_date): """Returns leave entries between from_date and to_date.""" - return frappe.db.sql( - """ - SELECT - employee, leave_type, from_date, to_date, leaves, transaction_name, transaction_type, holiday_list, - is_carry_forward, is_expired - FROM `tabLeave Ledger Entry` - WHERE employee=%(employee)s AND leave_type=%(leave_type)s - AND docstatus=1 - AND (leaves<0 - OR is_expired=1) - AND (from_date between %(from_date)s AND %(to_date)s - OR to_date between %(from_date)s AND %(to_date)s - OR (from_date < %(from_date)s AND to_date > %(to_date)s)) - """, - {"from_date": from_date, "to_date": to_date, "employee": employee, "leave_type": leave_type}, - as_dict=1, - ) + Ledger = frappe.qb.DocType("Leave Ledger Entry") + return ( + frappe.qb.from_(Ledger) + .select( + Ledger.employee, + Ledger.leave_type, + Ledger.from_date, + Ledger.to_date, + Ledger.leaves, + Ledger.transaction_name, + Ledger.transaction_type, + Ledger.holiday_list, + Ledger.is_carry_forward, + Ledger.is_expired, + ) + .where( + (Ledger.employee == employee) + & (Ledger.leave_type == leave_type) + & (Ledger.docstatus == 1) + & ((Ledger.leaves < 0) | (Ledger.is_expired == 1)) + & ( + Ledger.from_date[from_date:to_date] + | Ledger.to_date[from_date:to_date] + | ((Ledger.from_date < from_date) & (Ledger.to_date > to_date)) + ) + ) + ).run(as_dict=1) @frappe.whitelist() diff --git a/hrms/hr/doctype/leave_ledger_entry/leave_ledger_entry.py b/hrms/hr/doctype/leave_ledger_entry/leave_ledger_entry.py index 08a476fba1..3f24022959 100644 --- a/hrms/hr/doctype/leave_ledger_entry/leave_ledger_entry.py +++ b/hrms/hr/doctype/leave_ledger_entry/leave_ledger_entry.py @@ -60,19 +60,18 @@ def on_cancel(self): def validate_leave_allocation_against_leave_application(ledger): """Checks that leave allocation has no leave application against it""" - leave_application_records = frappe.db.sql_list( - """ - SELECT transaction_name - FROM `tabLeave Ledger Entry` - WHERE - employee=%s - AND leave_type=%s - AND transaction_type='Leave Application' - AND from_date>=%s - AND to_date<=%s - """, - (ledger.employee, ledger.leave_type, ledger.from_date, ledger.to_date), - ) + Ledger = frappe.qb.DocType("Leave Ledger Entry") + leave_application_records = ( + frappe.qb.from_(Ledger) + .select(Ledger.transaction_name) + .where( + (Ledger.employee == ledger.employee) + & (Ledger.leave_type == ledger.leave_type) + & (Ledger.transaction_type == "Leave Application") + & (Ledger.from_date >= ledger.from_date) + & (Ledger.to_date <= ledger.to_date) + ) + ).run(pluck=True) if leave_application_records: frappe.throw( @@ -171,7 +170,7 @@ def process_expired_allocation(): expire_allocation = frappe.db.sql( """ SELECT - leaves, to_date, from_date, employee, leave_type, + leaves, `to_date`, `from_date`, employee, leave_type, is_carry_forward, transaction_name as name, transaction_type FROM `tabLeave Ledger Entry` l WHERE (NOT EXISTS @@ -187,7 +186,7 @@ def process_expired_allocation(): OR (is_carry_forward = 0 AND leave_type not in %s) ))) AND transaction_type = 'Leave Allocation' - AND to_date < %s""", + AND `to_date` < %s""", (leave_type, today()), as_dict=1, ) diff --git a/hrms/hr/utils.py b/hrms/hr/utils.py index cdd6f68df4..a53237475d 100644 --- a/hrms/hr/utils.py +++ b/hrms/hr/utils.py @@ -240,9 +240,9 @@ def get_doc_condition(doctype): or work_end_date between %(from_date)s and %(to_date)s \ or (work_from_date < %(from_date)s and work_end_date > %(to_date)s))" elif doctype == "Leave Period": - return "and company = %(company)s and (from_date between %(from_date)s and %(to_date)s \ - or to_date between %(from_date)s and %(to_date)s \ - or (from_date < %(from_date)s and to_date > %(to_date)s))" + return "and company = %(company)s and (`from_date` between %(from_date)s and %(to_date)s \ + or `to_date` between %(from_date)s and %(to_date)s \ + or (`from_date` < %(from_date)s and `to_date` > %(to_date)s))" def throw_overlap_error(doc, exists_for, overlap_doc, from_date, to_date): @@ -310,18 +310,20 @@ def get_total_exemption_amount(declarations): @frappe.whitelist() def get_leave_period(from_date: str | datetime.date, to_date: str | datetime.date, company: str): - leave_period = frappe.db.sql( - """ - select name, from_date, to_date - from `tabLeave Period` - where company=%(company)s and is_active=1 - and (from_date between %(from_date)s and %(to_date)s - or to_date between %(from_date)s and %(to_date)s - or (from_date < %(from_date)s and to_date > %(to_date)s)) - """, - {"from_date": from_date, "to_date": to_date, "company": company}, - as_dict=1, - ) + LeavePeriod = frappe.qb.DocType("Leave Period") + leave_period = ( + frappe.qb.from_(LeavePeriod) + .select(LeavePeriod.name, LeavePeriod.from_date, LeavePeriod.to_date) + .where( + (LeavePeriod.company == company) + & (LeavePeriod.is_active == 1) + & ( + LeavePeriod.from_date[from_date:to_date] + | LeavePeriod.to_date[from_date:to_date] + | ((LeavePeriod.from_date < from_date) & (LeavePeriod.to_date > to_date)) + ) + ) + ).run(as_dict=1) if leave_period: return leave_period From 10a12622455d4e0d683fe7b03b1143b4601924f9 Mon Sep 17 00:00:00 2001 From: iamkhanraheel Date: Wed, 15 Jul 2026 12:20:29 +0530 Subject: [PATCH 7/7] test: add half-yearly earned leave schedule tests for mid-month joining date --- .../test_leave_policy_assignment.py | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/hrms/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py b/hrms/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py index 101e326246..707429ec12 100644 --- a/hrms/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py +++ b/hrms/hr/doctype/leave_policy_assignment/test_leave_policy_assignment.py @@ -420,3 +420,108 @@ def test_half_yearly_earned_leave_schedule_based_on_joining_date(self): self.assertIn(getdate("2027-03-31"), allocation_dates) self.assertNotIn(getdate("2026-06-30"), allocation_dates) self.assertEqual(len(allocation_dates), 2) + + def test_half_yearly_earned_leave_schedule_based_on_joining_date_mid_month_first_day(self): + """ + Employee joins: 27-May-2026 (mid-month), assignment created: 03-Jul-2026 + Period 1: 27-May-2026 → 26-Nov-2026, Period 2: 27-Nov-2026 → 26-May-2027 + Expected allocation dates: 03-Jul-2026 (period 1 credited on assignment date), 27-Nov-2026 + """ + self.employee.date_of_joining = getdate("2026-05-27") + self.employee.save() + + leave_type = create_leave_type( + leave_type_name="_Test Half Yearly Earned Leave Joining Date First Day", + is_earned_leave=True, + earned_leave_frequency="Half-Yearly", + allocate_on_day="First Day", + ) + annual_allocation = 18 + leave_policy = create_leave_policy(leave_type=leave_type.name, annual_allocation=annual_allocation) + leave_policy.submit() + + # assignment created mid-period + frappe.flags.current_date = getdate("2026-07-03") + + data = frappe._dict( + { + "assignment_based_on": "Joining Date", + "leave_policy": leave_policy.name, + "effective_from": self.employee.date_of_joining, + "effective_to": getdate("2027-05-26"), + } + ) + assignment = create_assignment(self.employee.name, data) + assignment.submit() + + allocation_name = frappe.db.get_value( + "Leave Allocation", {"leave_policy_assignment": assignment.name}, "name" + ) + schedule = frappe.get_all( + "Earned Leave Schedule", + filters={"parent": allocation_name}, + fields=["allocation_date"], + order_by="allocation_date asc", + ) + + allocation_dates = [getdate(row.allocation_date) for row in schedule] + + self.assertIn(getdate("2026-07-03"), allocation_dates) + self.assertIn(getdate("2026-11-27"), allocation_dates) + self.assertNotIn(getdate("2026-06-30"), allocation_dates) + self.assertNotIn(getdate("2027-05-27"), allocation_dates) + + # should be exactly 2 allocations, not 3 + self.assertEqual(len(allocation_dates), 2) + + def test_half_yearly_earned_leave_schedule_based_on_joining_date_mid_month_last_day(self): + """ + Employee joins: 27-May-2026 (mid-month), assignment created: 03-Jul-2026 + Period 1: 27-May-2026 → 26-Nov-2026, Period 2: 27-Nov-2026 → 26-May-2027 + Expected allocation dates: 26-Nov-2026, 26-May-2027 + """ + self.employee.date_of_joining = getdate("2026-05-27") + self.employee.save() + + leave_type = create_leave_type( + leave_type_name="_Test Half Yearly Earned Leave Joining Date Last Day", + is_earned_leave=True, + earned_leave_frequency="Half-Yearly", + allocate_on_day="Last Day", + ) + annual_allocation = 18 + leave_policy = create_leave_policy(leave_type=leave_type.name, annual_allocation=annual_allocation) + leave_policy.submit() + + # assignment created mid-period + frappe.flags.current_date = getdate("2026-07-03") + + data = frappe._dict( + { + "assignment_based_on": "Joining Date", + "leave_policy": leave_policy.name, + "effective_from": self.employee.date_of_joining, + "effective_to": getdate("2027-05-26"), + } + ) + assignment = create_assignment(self.employee.name, data) + assignment.submit() + + allocation_name = frappe.db.get_value( + "Leave Allocation", {"leave_policy_assignment": assignment.name}, "name" + ) + schedule = frappe.get_all( + "Earned Leave Schedule", + filters={"parent": allocation_name}, + fields=["allocation_date"], + order_by="allocation_date asc", + ) + + allocation_dates = [getdate(row.allocation_date) for row in schedule] + + self.assertIn(getdate("2026-11-26"), allocation_dates) + self.assertIn(getdate("2027-05-26"), allocation_dates) + self.assertNotIn(getdate("2026-06-30"), allocation_dates) + + # should be exactly 2 allocations, not 3 + self.assertEqual(len(allocation_dates), 2)