From c26a25ca06a7b42e95447c7dc9e60119a9a1539d Mon Sep 17 00:00:00 2001 From: Mohsin Akhtar <167299936+akhtarmohsin@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:18:24 +0530 Subject: [PATCH 01/14] feat(report): enhance Employee Advance Summary report - Rename Balance column to Outstanding Amount - Add Department, Branch, Advance Account filters with multi-select support - Add Group By filter (Employee, Department, Branch) with subtotals and grand total - Add Advance Account column to report output - Resolve department from Employee record when not set on Employee Advance - Add bold formatter for group header rows Co-Authored-By: Claude Sonnet 4.6 --- .../employee_advance_summary.js | 45 ++++ .../employee_advance_summary.py | 215 ++++++++++++++++-- 2 files changed, 237 insertions(+), 23 deletions(-) diff --git a/hrms/hr/report/employee_advance_summary/employee_advance_summary.js b/hrms/hr/report/employee_advance_summary/employee_advance_summary.js index a55a1dd1af..739e94d603 100644 --- a/hrms/hr/report/employee_advance_summary/employee_advance_summary.js +++ b/hrms/hr/report/employee_advance_summary/employee_advance_summary.js @@ -38,5 +38,50 @@ frappe.query_reports["Employee Advance Summary"] = { fieldtype: "Select", options: "\nDraft\nPaid\nPartially Paid\nUnpaid\nClaimed\nCancelled", }, + { + fieldname: "department", + label: __("Department"), + fieldtype: "MultiSelectList", + options: "Department", + get_data: function (txt) { + return frappe.db.get_link_options("Department", txt); + }, + }, + { + fieldname: "branch", + label: __("Branch"), + fieldtype: "MultiSelectList", + options: "Branch", + get_data: function (txt) { + return frappe.db.get_link_options("Branch", txt); + }, + }, + { + fieldname: "advance_account", + label: __("Advance Account"), + fieldtype: "MultiSelectList", + options: "Account", + get_data: function (txt) { + var company = frappe.query_report.get_filter_value("company"); + return frappe.db.get_link_options("Account", txt, { + company: company, + account_type: "Receivable", + }); + }, + }, + { + fieldname: "group_by", + label: __("Group By"), + fieldtype: "Select", + options: "\nEmployee\nDepartment\nBranch", + }, ], + + formatter: function (value, row, column, data, default_formatter) { + value = default_formatter(value, row, column, data); + if (data && data.bold) { + value = `${value}`; + } + return value; + }, }; diff --git a/hrms/hr/report/employee_advance_summary/employee_advance_summary.py b/hrms/hr/report/employee_advance_summary/employee_advance_summary.py index 1a0997ea14..6641eaded4 100644 --- a/hrms/hr/report/employee_advance_summary/employee_advance_summary.py +++ b/hrms/hr/report/employee_advance_summary/employee_advance_summary.py @@ -7,6 +7,12 @@ import frappe from frappe import _, msgprint +GROUP_BY_FIELDS = { + "Employee": "employee", + "Department": "department", + "Branch": "branch", +} + def execute(filters=None): if not filters: @@ -20,22 +26,128 @@ def execute(filters=None): return columns, advances_list data = [] + group_totals = {} + groups_seen = [] + + group_by = filters.get("group_by") + group_field = GROUP_BY_FIELDS.get(group_by) if group_by else None + for advance in advances_list: - row = [ - advance.name, - advance.employee, - advance.company, - advance.posting_date, - advance.advance_amount, - advance.paid_amount, - advance.claimed_amount, - advance.return_amount, - advance.status, - advance.currency, - ] - data.append(row) - - return columns, data + balance = advance.paid_amount - (advance.claimed_amount + advance.return_amount) + + # Resolve department with fallback before computing group key + advance["department"] = advance.department or advance.employee_department + + group_key = advance.get(group_field) if group_field else None + + if group_field and group_key not in group_totals: + groups_seen.append(group_key) + group_totals[group_key] = { + "advance_amount": 0, + "paid_amount": 0, + "claimed_amount": 0, + "return_amount": 0, + "balance": 0, + "currency": advance.currency, + } + + if group_field: + group_totals[group_key]["advance_amount"] += advance.advance_amount or 0 + group_totals[group_key]["paid_amount"] += advance.paid_amount or 0 + group_totals[group_key]["claimed_amount"] += advance.claimed_amount or 0 + group_totals[group_key]["return_amount"] += advance.return_amount or 0 + group_totals[group_key]["balance"] += balance + + data.append( + frappe._dict( + { + "title": advance.name, + "employee": advance.employee, + "advance_account": advance.advance_account, + "department": advance.department, + "branch": advance.branch, + "company": advance.company, + "posting_date": advance.posting_date, + "advance_amount": advance.advance_amount, + "paid_amount": advance.paid_amount, + "claimed_amount": advance.claimed_amount, + "return_amount": advance.return_amount, + "balance": balance, + "status": advance.status, + "currency": advance.currency, + "_group_key": group_key, + "indent": 1 if group_field else 0, + } + ) + ) + + if not group_field: + return columns, data + + # Build grouped result with subtotal header rows + manual grand total + group_rows = {} + for row in data: + group_rows.setdefault(row["_group_key"], []).append(row) + + result = [] + grand_total = { + "advance_amount": 0, + "paid_amount": 0, + "claimed_amount": 0, + "return_amount": 0, + "balance": 0, + } + first_currency = None + + for key in groups_seen: + totals = group_totals[key] + rows = group_rows.get(key, []) + if not rows: + continue + + if not first_currency: + first_currency = totals["currency"] + + grand_total["advance_amount"] += totals["advance_amount"] + grand_total["paid_amount"] += totals["paid_amount"] + grand_total["claimed_amount"] += totals["claimed_amount"] + grand_total["return_amount"] += totals["return_amount"] + grand_total["balance"] += totals["balance"] + + result.append( + frappe._dict( + { + "title": key or _("Not Set"), + "advance_amount": totals["advance_amount"], + "paid_amount": totals["paid_amount"], + "claimed_amount": totals["claimed_amount"], + "return_amount": totals["return_amount"], + "balance": totals["balance"], + "currency": totals["currency"], + "bold": 1, + "indent": 0, + } + ) + ) + result.extend(rows) + + result.append( + frappe._dict( + { + "title": _("Total"), + "advance_amount": grand_total["advance_amount"], + "paid_amount": grand_total["paid_amount"], + "claimed_amount": grand_total["claimed_amount"], + "return_amount": grand_total["return_amount"], + "balance": grand_total["balance"], + "currency": first_currency, + "bold": 1, + "indent": 0, + } + ) + ) + + return columns, result, None, None, None, 1 def get_columns(): @@ -45,7 +157,7 @@ def get_columns(): "fieldname": "title", "fieldtype": "Link", "options": "Employee Advance", - "width": 120, + "width": 160, }, { "label": _("Employee"), @@ -54,6 +166,27 @@ def get_columns(): "options": "Employee", "width": 120, }, + { + "label": _("Advance Account"), + "fieldname": "advance_account", + "fieldtype": "Link", + "options": "Account", + "width": 160, + }, + { + "label": _("Department"), + "fieldname": "department", + "fieldtype": "Link", + "options": "Department", + "width": 120, + }, + { + "label": _("Branch"), + "fieldname": "branch", + "fieldtype": "Link", + "options": "Branch", + "width": 120, + }, { "label": _("Company"), "fieldname": "company", @@ -67,7 +200,7 @@ def get_columns(): "fieldname": "advance_amount", "fieldtype": "Currency", "options": "currency", - "width": 120, + "width": 130, }, { "label": _("Paid Amount"), @@ -81,14 +214,21 @@ def get_columns(): "fieldname": "claimed_amount", "fieldtype": "Currency", "options": "currency", - "width": 120, + "width": 130, }, { "label": _("Returned Amount"), "fieldname": "return_amount", "fieldtype": "Currency", "options": "currency", - "width": 120, + "width": 130, + }, + { + "label": _("Outstanding Amount"), + "fieldname": "balance", + "fieldtype": "Currency", + "options": "currency", + "width": 140, }, {"label": _("Status"), "fieldname": "status", "fieldtype": "Data", "width": 120}, { @@ -104,12 +244,20 @@ def get_columns(): def get_advances(filters): EmployeeAdvance = frappe.qb.DocType("Employee Advance") + Employee = frappe.qb.DocType("Employee") query = ( frappe.qb.from_(EmployeeAdvance) + .left_join(Employee) + .on(EmployeeAdvance.employee == Employee.name) .select( EmployeeAdvance.name, EmployeeAdvance.employee, + Employee.employee_name, + Employee.branch, + Employee.department.as_("employee_department"), + EmployeeAdvance.advance_account, + EmployeeAdvance.department, EmployeeAdvance.paid_amount, EmployeeAdvance.status, EmployeeAdvance.advance_amount, @@ -117,7 +265,6 @@ def get_advances(filters): EmployeeAdvance.return_amount, EmployeeAdvance.company, EmployeeAdvance.posting_date, - EmployeeAdvance.purpose, EmployeeAdvance.currency, ) .where(EmployeeAdvance.docstatus < 2) @@ -138,6 +285,28 @@ def get_advances(filters): if filters.get("to_date"): query = query.where(EmployeeAdvance.posting_date <= filters.to_date) - return query.orderby(EmployeeAdvance.posting_date, EmployeeAdvance.name, order=Order.desc).run( - as_dict=True - ) + if filters.get("department"): + departments = filters.get("department") + query = query.where( + (EmployeeAdvance.department.isin(departments)) + | (EmployeeAdvance.department.isnull() & (Employee.department.isin(departments))) + ) + + if filters.get("branch"): + query = query.where(Employee.branch.isin(filters.get("branch"))) + + if filters.get("advance_account"): + query = query.where(EmployeeAdvance.advance_account.isin(filters.get("advance_account"))) + + # Order by the group-by field so groups are contiguous + group_by = filters.get("group_by") + if group_by == "Department": + query = query.orderby(EmployeeAdvance.department) + elif group_by == "Branch": + query = query.orderby(Employee.branch) + else: + query = query.orderby(EmployeeAdvance.employee) + + query = query.orderby(EmployeeAdvance.posting_date, order=Order.desc) + + return query.run(as_dict=True) From 4f53f301c57ea8407fdee771f78ddbfe4ae84ed7 Mon Sep 17 00:00:00 2001 From: Mohsin Akhtar <167299936+akhtarmohsin@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:33:35 +0530 Subject: [PATCH 02/14] refactor(report): follow frappe/hrms conventions in Employee Advance Summary - Use frappe._dict(filters) and dot-access instead of filters.get() - Use frappe.utils.scrub() to derive group field from group_by label - Use OrderedDict for deterministic group ordering (as in gross_profit report) - Remove manual GROUP_BY_FIELDS mapping - Use frappe.query_builder.Order instead of pypika.Order - Build result rows inline during grouping iteration Co-Authored-By: Claude Sonnet 4.6 --- .../employee_advance_summary.py | 207 ++++++++---------- 1 file changed, 89 insertions(+), 118 deletions(-) diff --git a/hrms/hr/report/employee_advance_summary/employee_advance_summary.py b/hrms/hr/report/employee_advance_summary/employee_advance_summary.py index 6641eaded4..d09f82d632 100644 --- a/hrms/hr/report/employee_advance_summary/employee_advance_summary.py +++ b/hrms/hr/report/employee_advance_summary/employee_advance_summary.py @@ -1,22 +1,18 @@ # Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt - -from pypika import Order +from collections import OrderedDict import frappe from frappe import _, msgprint - -GROUP_BY_FIELDS = { - "Employee": "employee", - "Department": "department", - "Branch": "branch", -} +from frappe.query_builder import Order +from frappe.utils import scrub def execute(filters=None): if not filters: - filters = {} + filters = frappe._dict() + filters = frappe._dict(filters) advances_list = get_advances(filters) columns = get_columns() @@ -25,125 +21,102 @@ def execute(filters=None): msgprint(_("No record found")) return columns, advances_list - data = [] - group_totals = {} - groups_seen = [] + group_by = filters.group_by + group_field = scrub(group_by) if group_by else None - group_by = filters.get("group_by") - group_field = GROUP_BY_FIELDS.get(group_by) if group_by else None + grouped = OrderedDict() + group_totals = OrderedDict() for advance in advances_list: - balance = advance.paid_amount - (advance.claimed_amount + advance.return_amount) - - # Resolve department with fallback before computing group key - advance["department"] = advance.department or advance.employee_department + advance.balance = advance.paid_amount - (advance.claimed_amount + advance.return_amount) + advance.department = advance.department or advance.employee_department group_key = advance.get(group_field) if group_field else None - if group_field and group_key not in group_totals: - groups_seen.append(group_key) - group_totals[group_key] = { - "advance_amount": 0, - "paid_amount": 0, - "claimed_amount": 0, - "return_amount": 0, - "balance": 0, - "currency": advance.currency, - } - if group_field: - group_totals[group_key]["advance_amount"] += advance.advance_amount or 0 - group_totals[group_key]["paid_amount"] += advance.paid_amount or 0 - group_totals[group_key]["claimed_amount"] += advance.claimed_amount or 0 - group_totals[group_key]["return_amount"] += advance.return_amount or 0 - group_totals[group_key]["balance"] += balance - - data.append( - frappe._dict( - { - "title": advance.name, - "employee": advance.employee, - "advance_account": advance.advance_account, - "department": advance.department, - "branch": advance.branch, - "company": advance.company, - "posting_date": advance.posting_date, - "advance_amount": advance.advance_amount, - "paid_amount": advance.paid_amount, - "claimed_amount": advance.claimed_amount, - "return_amount": advance.return_amount, - "balance": balance, - "status": advance.status, - "currency": advance.currency, - "_group_key": group_key, - "indent": 1 if group_field else 0, - } - ) - ) + grouped.setdefault(group_key, []).append(advance) + + if group_key not in group_totals: + group_totals[group_key] = frappe._dict( + advance_amount=0, + paid_amount=0, + claimed_amount=0, + return_amount=0, + balance=0, + currency=advance.currency, + ) + + group_totals[group_key].advance_amount += advance.advance_amount or 0 + group_totals[group_key].paid_amount += advance.paid_amount or 0 + group_totals[group_key].claimed_amount += advance.claimed_amount or 0 + group_totals[group_key].return_amount += advance.return_amount or 0 + group_totals[group_key].balance += advance.balance if not group_field: - return columns, data - - # Build grouped result with subtotal header rows + manual grand total - group_rows = {} - for row in data: - group_rows.setdefault(row["_group_key"], []).append(row) + return columns, advances_list result = [] - grand_total = { - "advance_amount": 0, - "paid_amount": 0, - "claimed_amount": 0, - "return_amount": 0, - "balance": 0, - } + grand_total = frappe._dict(advance_amount=0, paid_amount=0, claimed_amount=0, return_amount=0, balance=0) first_currency = None - for key in groups_seen: + for key, rows in grouped.items(): totals = group_totals[key] - rows = group_rows.get(key, []) - if not rows: - continue if not first_currency: - first_currency = totals["currency"] + first_currency = totals.currency - grand_total["advance_amount"] += totals["advance_amount"] - grand_total["paid_amount"] += totals["paid_amount"] - grand_total["claimed_amount"] += totals["claimed_amount"] - grand_total["return_amount"] += totals["return_amount"] - grand_total["balance"] += totals["balance"] + grand_total.advance_amount += totals.advance_amount + grand_total.paid_amount += totals.paid_amount + grand_total.claimed_amount += totals.claimed_amount + grand_total.return_amount += totals.return_amount + grand_total.balance += totals.balance result.append( frappe._dict( - { - "title": key or _("Not Set"), - "advance_amount": totals["advance_amount"], - "paid_amount": totals["paid_amount"], - "claimed_amount": totals["claimed_amount"], - "return_amount": totals["return_amount"], - "balance": totals["balance"], - "currency": totals["currency"], - "bold": 1, - "indent": 0, - } + title=key or _("Not Set"), + advance_amount=totals.advance_amount, + paid_amount=totals.paid_amount, + claimed_amount=totals.claimed_amount, + return_amount=totals.return_amount, + balance=totals.balance, + currency=totals.currency, + bold=1, + indent=0, ) ) - result.extend(rows) + + for row in rows: + result.append( + frappe._dict( + title=row.name, + employee=row.employee, + advance_account=row.advance_account, + department=row.department, + branch=row.branch, + company=row.company, + posting_date=row.posting_date, + advance_amount=row.advance_amount, + paid_amount=row.paid_amount, + claimed_amount=row.claimed_amount, + return_amount=row.return_amount, + balance=row.balance, + status=row.status, + currency=row.currency, + indent=1, + ) + ) result.append( frappe._dict( - { - "title": _("Total"), - "advance_amount": grand_total["advance_amount"], - "paid_amount": grand_total["paid_amount"], - "claimed_amount": grand_total["claimed_amount"], - "return_amount": grand_total["return_amount"], - "balance": grand_total["balance"], - "currency": first_currency, - "bold": 1, - "indent": 0, - } + title=_("Total"), + advance_amount=grand_total.advance_amount, + paid_amount=grand_total.paid_amount, + claimed_amount=grand_total.claimed_amount, + return_amount=grand_total.return_amount, + balance=grand_total.balance, + currency=first_currency, + bold=1, + indent=0, ) ) @@ -270,36 +243,34 @@ def get_advances(filters): .where(EmployeeAdvance.docstatus < 2) ) - if filters.get("employee"): + if filters.employee: query = query.where(EmployeeAdvance.employee == filters.employee) - if filters.get("company"): + if filters.company: query = query.where(EmployeeAdvance.company == filters.company) - if filters.get("status"): + if filters.status: query = query.where(EmployeeAdvance.status == filters.status) - if filters.get("from_date"): + if filters.from_date: query = query.where(EmployeeAdvance.posting_date >= filters.from_date) - if filters.get("to_date"): + if filters.to_date: query = query.where(EmployeeAdvance.posting_date <= filters.to_date) - if filters.get("department"): - departments = filters.get("department") + if filters.department: query = query.where( - (EmployeeAdvance.department.isin(departments)) - | (EmployeeAdvance.department.isnull() & (Employee.department.isin(departments))) + (EmployeeAdvance.department.isin(filters.department)) + | (EmployeeAdvance.department.isnull() & (Employee.department.isin(filters.department))) ) - if filters.get("branch"): - query = query.where(Employee.branch.isin(filters.get("branch"))) + if filters.branch: + query = query.where(Employee.branch.isin(filters.branch)) - if filters.get("advance_account"): - query = query.where(EmployeeAdvance.advance_account.isin(filters.get("advance_account"))) + if filters.advance_account: + query = query.where(EmployeeAdvance.advance_account.isin(filters.advance_account)) - # Order by the group-by field so groups are contiguous - group_by = filters.get("group_by") + group_by = filters.group_by if group_by == "Department": query = query.orderby(EmployeeAdvance.department) elif group_by == "Branch": From f0c922d34d3e758078b90d90c8a7aab841297491 Mon Sep 17 00:00:00 2001 From: Mohsin Akhtar <167299936+akhtarmohsin@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:46:10 +0530 Subject: [PATCH 03/14] refactor(report): rename balance to outstanding_amount Co-Authored-By: Claude Sonnet 4.6 --- .../employee_advance_summary.py | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/hrms/hr/report/employee_advance_summary/employee_advance_summary.py b/hrms/hr/report/employee_advance_summary/employee_advance_summary.py index d09f82d632..9bed86966c 100644 --- a/hrms/hr/report/employee_advance_summary/employee_advance_summary.py +++ b/hrms/hr/report/employee_advance_summary/employee_advance_summary.py @@ -28,7 +28,7 @@ def execute(filters=None): group_totals = OrderedDict() for advance in advances_list: - advance.balance = advance.paid_amount - (advance.claimed_amount + advance.return_amount) + advance.outstanding_amount = advance.paid_amount - (advance.claimed_amount + advance.return_amount) advance.department = advance.department or advance.employee_department group_key = advance.get(group_field) if group_field else None @@ -42,7 +42,7 @@ def execute(filters=None): paid_amount=0, claimed_amount=0, return_amount=0, - balance=0, + outstanding_amount=0, currency=advance.currency, ) @@ -50,13 +50,15 @@ def execute(filters=None): group_totals[group_key].paid_amount += advance.paid_amount or 0 group_totals[group_key].claimed_amount += advance.claimed_amount or 0 group_totals[group_key].return_amount += advance.return_amount or 0 - group_totals[group_key].balance += advance.balance + group_totals[group_key].outstanding_amount += advance.outstanding_amount if not group_field: return columns, advances_list result = [] - grand_total = frappe._dict(advance_amount=0, paid_amount=0, claimed_amount=0, return_amount=0, balance=0) + grand_total = frappe._dict( + advance_amount=0, paid_amount=0, claimed_amount=0, return_amount=0, outstanding_amount=0 + ) first_currency = None for key, rows in grouped.items(): @@ -69,7 +71,7 @@ def execute(filters=None): grand_total.paid_amount += totals.paid_amount grand_total.claimed_amount += totals.claimed_amount grand_total.return_amount += totals.return_amount - grand_total.balance += totals.balance + grand_total.outstanding_amount += totals.outstanding_amount result.append( frappe._dict( @@ -78,7 +80,7 @@ def execute(filters=None): paid_amount=totals.paid_amount, claimed_amount=totals.claimed_amount, return_amount=totals.return_amount, - balance=totals.balance, + outstanding_amount=totals.outstanding_amount, currency=totals.currency, bold=1, indent=0, @@ -99,7 +101,7 @@ def execute(filters=None): paid_amount=row.paid_amount, claimed_amount=row.claimed_amount, return_amount=row.return_amount, - balance=row.balance, + outstanding_amount=row.outstanding_amount, status=row.status, currency=row.currency, indent=1, @@ -113,7 +115,7 @@ def execute(filters=None): paid_amount=grand_total.paid_amount, claimed_amount=grand_total.claimed_amount, return_amount=grand_total.return_amount, - balance=grand_total.balance, + outstanding_amount=grand_total.outstanding_amount, currency=first_currency, bold=1, indent=0, @@ -198,7 +200,7 @@ def get_columns(): }, { "label": _("Outstanding Amount"), - "fieldname": "balance", + "fieldname": "outstanding_amount", "fieldtype": "Currency", "options": "currency", "width": 140, From f6eb09e0aee430ff0c06aceab321fe2b0c6c9187 Mon Sep 17 00:00:00 2001 From: Mohsin Akhtar <167299936+akhtarmohsin@users.noreply.github.com> Date: Thu, 18 Jun 2026 23:52:22 +0530 Subject: [PATCH 04/14] fix(report): show Title and Outstanding Amount in flat view Co-Authored-By: Claude Sonnet 4.6 --- .../employee_advance_summary/employee_advance_summary.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/hrms/hr/report/employee_advance_summary/employee_advance_summary.py b/hrms/hr/report/employee_advance_summary/employee_advance_summary.py index 9bed86966c..b187bc234a 100644 --- a/hrms/hr/report/employee_advance_summary/employee_advance_summary.py +++ b/hrms/hr/report/employee_advance_summary/employee_advance_summary.py @@ -53,6 +53,10 @@ def execute(filters=None): group_totals[group_key].outstanding_amount += advance.outstanding_amount if not group_field: + for row in advances_list: + row.title = row.name + row.outstanding_amount = row.paid_amount - (row.claimed_amount + row.return_amount) + row.department = row.department or row.employee_department return columns, advances_list result = [] From 439b9b50fa2b20544a969881608ec471d00e9b62 Mon Sep 17 00:00:00 2001 From: Mohsin Akhtar <167299936+akhtarmohsin@users.noreply.github.com> Date: Fri, 19 Jun 2026 00:06:06 +0530 Subject: [PATCH 05/14] fix(report): show employee name in group header when grouping by Employee Co-Authored-By: Claude Sonnet 4.6 --- .../employee_advance_summary/employee_advance_summary.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/hrms/hr/report/employee_advance_summary/employee_advance_summary.py b/hrms/hr/report/employee_advance_summary/employee_advance_summary.py index b187bc234a..1e7ff46886 100644 --- a/hrms/hr/report/employee_advance_summary/employee_advance_summary.py +++ b/hrms/hr/report/employee_advance_summary/employee_advance_summary.py @@ -26,6 +26,7 @@ def execute(filters=None): grouped = OrderedDict() group_totals = OrderedDict() + group_labels = OrderedDict() for advance in advances_list: advance.outstanding_amount = advance.paid_amount - (advance.claimed_amount + advance.return_amount) @@ -37,6 +38,11 @@ def execute(filters=None): grouped.setdefault(group_key, []).append(advance) if group_key not in group_totals: + # For Employee grouping, show employee_name as the header label + group_labels[group_key] = ( + advance.employee_name if group_by == "Employee" else group_key + ) or group_key + group_totals[group_key] = frappe._dict( advance_amount=0, paid_amount=0, @@ -79,7 +85,7 @@ def execute(filters=None): result.append( frappe._dict( - title=key or _("Not Set"), + title=group_labels.get(key) or _("Not Set"), advance_amount=totals.advance_amount, paid_amount=totals.paid_amount, claimed_amount=totals.claimed_amount, From 36d17ffddc46ddc265410a9b26759d455ca17abe Mon Sep 17 00:00:00 2001 From: Mohsin Akhtar <167299936+akhtarmohsin@users.noreply.github.com> Date: Fri, 19 Jun 2026 00:09:24 +0530 Subject: [PATCH 06/14] feat(report): show employee ID and name together in Employee column and group header Co-Authored-By: Claude Sonnet 4.6 --- .../employee_advance_summary.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/hrms/hr/report/employee_advance_summary/employee_advance_summary.py b/hrms/hr/report/employee_advance_summary/employee_advance_summary.py index 1e7ff46886..dc13130e02 100644 --- a/hrms/hr/report/employee_advance_summary/employee_advance_summary.py +++ b/hrms/hr/report/employee_advance_summary/employee_advance_summary.py @@ -38,9 +38,11 @@ def execute(filters=None): grouped.setdefault(group_key, []).append(advance) if group_key not in group_totals: - # For Employee grouping, show employee_name as the header label + # For Employee grouping, show "ID: Name" as the header label group_labels[group_key] = ( - advance.employee_name if group_by == "Employee" else group_key + f"{advance.employee}: {advance.employee_name}" + if group_by == "Employee" and advance.employee_name + else group_key ) or group_key group_totals[group_key] = frappe._dict( @@ -63,6 +65,8 @@ def execute(filters=None): row.title = row.name row.outstanding_amount = row.paid_amount - (row.claimed_amount + row.return_amount) row.department = row.department or row.employee_department + if row.employee_name: + row.employee = f"{row.employee}: {row.employee_name}" return columns, advances_list result = [] @@ -101,7 +105,7 @@ def execute(filters=None): result.append( frappe._dict( title=row.name, - employee=row.employee, + employee=f"{row.employee}: {row.employee_name}" if row.employee_name else row.employee, advance_account=row.advance_account, department=row.department, branch=row.branch, @@ -147,9 +151,8 @@ def get_columns(): { "label": _("Employee"), "fieldname": "employee", - "fieldtype": "Link", - "options": "Employee", - "width": 120, + "fieldtype": "Data", + "width": 180, }, { "label": _("Advance Account"), From f296ae9904cc58e408e7eb28497bd10dab0a95c4 Mon Sep 17 00:00:00 2001 From: Mohsin Akhtar <167299936+akhtarmohsin@users.noreply.github.com> Date: Fri, 19 Jun 2026 00:32:09 +0530 Subject: [PATCH 07/14] refactor(report): use filters.get() and scrub() inline for group_by - Revert filters.key dot-access to filters.get("key") to match hrms conventions - Remove group_field variable; use scrub(filters.get("group_by")) inline matching gross_profit report pattern - Show employee ID and name in Employee column and group header Co-Authored-By: Claude Sonnet 4.6 --- .../employee_advance_summary.py | 51 +++++++++---------- 1 file changed, 23 insertions(+), 28 deletions(-) diff --git a/hrms/hr/report/employee_advance_summary/employee_advance_summary.py b/hrms/hr/report/employee_advance_summary/employee_advance_summary.py index dc13130e02..d602823de2 100644 --- a/hrms/hr/report/employee_advance_summary/employee_advance_summary.py +++ b/hrms/hr/report/employee_advance_summary/employee_advance_summary.py @@ -21,9 +21,6 @@ def execute(filters=None): msgprint(_("No record found")) return columns, advances_list - group_by = filters.group_by - group_field = scrub(group_by) if group_by else None - grouped = OrderedDict() group_totals = OrderedDict() group_labels = OrderedDict() @@ -32,16 +29,15 @@ def execute(filters=None): advance.outstanding_amount = advance.paid_amount - (advance.claimed_amount + advance.return_amount) advance.department = advance.department or advance.employee_department - group_key = advance.get(group_field) if group_field else None + if filters.get("group_by"): + group_key = advance.get(scrub(filters.get("group_by"))) - if group_field: grouped.setdefault(group_key, []).append(advance) if group_key not in group_totals: - # For Employee grouping, show "ID: Name" as the header label group_labels[group_key] = ( f"{advance.employee}: {advance.employee_name}" - if group_by == "Employee" and advance.employee_name + if filters.get("group_by") == "Employee" and advance.employee_name else group_key ) or group_key @@ -60,7 +56,7 @@ def execute(filters=None): group_totals[group_key].return_amount += advance.return_amount or 0 group_totals[group_key].outstanding_amount += advance.outstanding_amount - if not group_field: + if not filters.get("group_by"): for row in advances_list: row.title = row.name row.outstanding_amount = row.paid_amount - (row.claimed_amount + row.return_amount) @@ -258,37 +254,36 @@ def get_advances(filters): .where(EmployeeAdvance.docstatus < 2) ) - if filters.employee: - query = query.where(EmployeeAdvance.employee == filters.employee) + if filters.get("employee"): + query = query.where(EmployeeAdvance.employee == filters.get("employee")) - if filters.company: - query = query.where(EmployeeAdvance.company == filters.company) + if filters.get("company"): + query = query.where(EmployeeAdvance.company == filters.get("company")) - if filters.status: - query = query.where(EmployeeAdvance.status == filters.status) + if filters.get("status"): + query = query.where(EmployeeAdvance.status == filters.get("status")) - if filters.from_date: - query = query.where(EmployeeAdvance.posting_date >= filters.from_date) + if filters.get("from_date"): + query = query.where(EmployeeAdvance.posting_date >= filters.get("from_date")) - if filters.to_date: - query = query.where(EmployeeAdvance.posting_date <= filters.to_date) + if filters.get("to_date"): + query = query.where(EmployeeAdvance.posting_date <= filters.get("to_date")) - if filters.department: + if filters.get("department"): query = query.where( - (EmployeeAdvance.department.isin(filters.department)) - | (EmployeeAdvance.department.isnull() & (Employee.department.isin(filters.department))) + (EmployeeAdvance.department.isin(filters.get("department"))) + | (EmployeeAdvance.department.isnull() & (Employee.department.isin(filters.get("department")))) ) - if filters.branch: - query = query.where(Employee.branch.isin(filters.branch)) + if filters.get("branch"): + query = query.where(Employee.branch.isin(filters.get("branch"))) - if filters.advance_account: - query = query.where(EmployeeAdvance.advance_account.isin(filters.advance_account)) + if filters.get("advance_account"): + query = query.where(EmployeeAdvance.advance_account.isin(filters.get("advance_account"))) - group_by = filters.group_by - if group_by == "Department": + if filters.get("group_by") == "Department": query = query.orderby(EmployeeAdvance.department) - elif group_by == "Branch": + elif filters.get("group_by") == "Branch": query = query.orderby(Employee.branch) else: query = query.orderby(EmployeeAdvance.employee) From fbcddcf5ad8e9d04e7862cc2517a6d96b8e57c2f Mon Sep 17 00:00:00 2001 From: Pratheep Selvam Date: Sat, 20 Jun 2026 22:52:41 +0530 Subject: [PATCH 08/14] fix: add permission check for insert_shift mutations --- hrms/api/roster.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hrms/api/roster.py b/hrms/api/roster.py index effb62a7a6..acb2c13286 100644 --- a/hrms/api/roster.py +++ b/hrms/api/roster.py @@ -234,13 +234,16 @@ def insert_shift( ) if prev_shift: + frappe.has_permission("Shift Assignment", "write", prev_shift, throw=True) if next_shift: + frappe.has_permission("Shift Assignment", "write", next_shift, throw=True) end_date = frappe.db.get_value("Shift Assignment", next_shift, "end_date") frappe.db.set_value("Shift Assignment", next_shift, "docstatus", 2) frappe.delete_doc("Shift Assignment", next_shift) frappe.db.set_value("Shift Assignment", prev_shift, "end_date", end_date or None) elif next_shift: + frappe.has_permission("Shift Assignment", "write", next_shift, throw=True) frappe.db.set_value("Shift Assignment", next_shift, "start_date", start_date) else: From fd6161cbfcf7b6a831ed8a1133cef75dc3143298 Mon Sep 17 00:00:00 2001 From: Pratheep Selvam Date: Sat, 20 Jun 2026 22:57:58 +0530 Subject: [PATCH 09/14] fix: add delete permission check for next_shift --- hrms/api/roster.py | 1 + 1 file changed, 1 insertion(+) diff --git a/hrms/api/roster.py b/hrms/api/roster.py index acb2c13286..6a2c249d03 100644 --- a/hrms/api/roster.py +++ b/hrms/api/roster.py @@ -238,6 +238,7 @@ def insert_shift( if next_shift: frappe.has_permission("Shift Assignment", "write", next_shift, throw=True) end_date = frappe.db.get_value("Shift Assignment", next_shift, "end_date") + frappe.has_permission("Shift Assignment", "delete", next_shift, throw=True) frappe.db.set_value("Shift Assignment", next_shift, "docstatus", 2) frappe.delete_doc("Shift Assignment", next_shift) frappe.db.set_value("Shift Assignment", prev_shift, "end_date", end_date or None) From 19237ed535b37bb1ef1728f6b1688eab9e15889d Mon Sep 17 00:00:00 2001 From: iamkhanraheel Date: Tue, 23 Jun 2026 19:29:00 +0530 Subject: [PATCH 10/14] fix(employee_payment_entry): set source exchange rate on payment entry creation to avoid empty exchange gain/loss account error --- hrms/overrides/employee_payment_entry.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/hrms/overrides/employee_payment_entry.py b/hrms/overrides/employee_payment_entry.py index 2c61ae2ea9..87d21d3ba0 100644 --- a/hrms/overrides/employee_payment_entry.py +++ b/hrms/overrides/employee_payment_entry.py @@ -143,10 +143,9 @@ def get_payment_entry_for_employee( pe.received_amount = received_amount if party_account and bank: - if dt == "Employee Advance": + pe.set_exchange_rate() # always set source & target exchange rate + if dt == "Employee Advance" and pe.paid_to_account_currency != pe.paid_from_account_currency: pe.target_exchange_rate = current_exchange_rate - else: - pe.set_exchange_rate() pe.set_amounts() return pe From f5e01caf8e178c87d010396d4d959c8eb6169874 Mon Sep 17 00:00:00 2001 From: iamkhanraheel Date: Tue, 23 Jun 2026 19:59:28 +0530 Subject: [PATCH 11/14] test: verify no exchange gain/loss triggered for same currency advance payment --- .../employee_advance/test_employee_advance.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/hrms/hr/doctype/employee_advance/test_employee_advance.py b/hrms/hr/doctype/employee_advance/test_employee_advance.py index 02d8f5ba5d..a37ab6bbf5 100644 --- a/hrms/hr/doctype/employee_advance/test_employee_advance.py +++ b/hrms/hr/doctype/employee_advance/test_employee_advance.py @@ -402,6 +402,25 @@ def test_multicurrency_advance(self): self.assertEqual(advance.base_paid_amount, expected_base_paid) self.assertEqual(payment_entry.paid_amount, expected_base_paid) + def test_no_exchange_gain_loss_for_same_currency_advance_payment(self): + from hrms.overrides.employee_payment_entry import get_payment_entry_for_employee + + gain_loss_account = frappe.db.get_value("Company", "_Test Company", "exchange_gain_loss_account") + frappe.db.set_value("Company", "_Test Company", "exchange_gain_loss_account", None) + + try: + employee_name = make_employee("_T@employee.advance", "_Test Company") + advance = make_employee_advance(employee_name) + + # should not raise error even without exchange_gain_loss_account set at time of payment + pe = get_payment_entry_for_employee(advance.doctype, advance.name) + + self.assertEqual(flt(pe.source_exchange_rate), 1.0) + self.assertEqual(flt(pe.target_exchange_rate), 1.0) + self.assertFalse(any(d.is_exchange_gain_loss for d in pe.deductions)) + finally: + frappe.db.set_value("Company", "_Test Company", "exchange_gain_loss_account", gain_loss_account) + def test_status_on_discard(self): employee_name = make_employee("Test_status@employee.advance", "_Test Company") advance = make_employee_advance(employee_name, do_not_submit=True) From 70ca7d9fe09dc84ff6a737651965a0ad7a9ae8ad Mon Sep 17 00:00:00 2001 From: iamkhanraheel Date: Thu, 11 Jun 2026 17:46:38 +0530 Subject: [PATCH 12/14] fix: use leave period dates for half-yearly earned leave allocation --- .../leave_policy_assignment.py | 51 +++++++++++----- hrms/hr/utils.py | 60 +++++++++++++++++-- 2 files changed, 91 insertions(+), 20 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 2b58c4c388..9bed3d7b18 100644 --- a/hrms/hr/doctype/leave_policy_assignment/leave_policy_assignment.py +++ b/hrms/hr/doctype/leave_policy_assignment/leave_policy_assignment.py @@ -216,7 +216,10 @@ def get_new_leaves(self, annual_allocation, leave_details, date_of_joining): def get_leaves_for_passed_period(self, annual_allocation, leave_details, date_of_joining): consider_current_period = is_earned_leave_applicable_for_current_period( - date_of_joining, leave_details.allocate_on_day, leave_details.earned_leave_frequency + date_of_joining, + leave_details.allocate_on_day, + leave_details.earned_leave_frequency, + effective_from=self.effective_from, ) current_date, from_date = self.get_current_and_from_date(date_of_joining) periods_passed = self.get_periods_passed( @@ -276,7 +279,9 @@ def calculate_leaves_for_passed_period( # calculate pro-rated leave for that month # and normal monthly earned leave for remaining passed months start_date, end_date = 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, ) leaves = get_periodically_earned_leave( date_of_joining, @@ -331,7 +336,11 @@ def get_earned_leave_schedule( "attempted": 1, } ) - last_allocated_date = get_sub_period_start_and_end(today, leave_details.earned_leave_frequency)[1] + last_allocated_date = get_sub_period_start_and_end( + today, + leave_details.earned_leave_frequency, + effective_from=self.effective_from, + )[1] while date <= to_date: date_already_passed = today > date @@ -349,6 +358,7 @@ def get_earned_leave_schedule( leave_details.allocate_on_day, add_to_date(date, months=months_to_add), date_of_joining, + effective_from=self.effective_from, ) if from_date < getdate(date_of_joining): pro_rated_period_start, pro_rated_period_end = get_sub_period_start_and_end( @@ -394,13 +404,26 @@ def calculate_periods_passed( return periods_passed -def is_earned_leave_applicable_for_current_period(date_of_joining, allocate_on_day, earned_leave_frequency): - from hrms.hr.utils import get_semester_end, get_semester_start - +def is_earned_leave_applicable_for_current_period( + date_of_joining, allocate_on_day, earned_leave_frequency, effective_from=None +): date = getdate(frappe.flags.current_date) or getdate() - # If the date of assignment creation is >= the leave type's "Allocate On" date, - # then the current month should be considered - # because the employee is already entitled for the leave of that month + + # For Half-Yearly, compute preiod relative to effective_from + # instead of calendar year + if earned_leave_frequency == "Half-Yearly" and effective_from: + from hrms.hr.utils import get_period_relative_half_year + + period_start, period_end = get_period_relative_half_year(date, effective_from) + half_yearly_condition = (allocate_on_day == "First Day" and date >= period_start) or ( + allocate_on_day == "Last Day" and date == period_end + ) + else: + from hrms.hr.utils import get_semester_end, get_semester_start + + half_yearly_condition = (allocate_on_day == "First Day" and date >= get_semester_start(date)) or ( + allocate_on_day == "Last Day" and date == get_semester_end(date) + ) condition_map = { "Monthly": ( @@ -408,16 +431,16 @@ def is_earned_leave_applicable_for_current_period(date_of_joining, allocate_on_d or (allocate_on_day == "First Day" and date >= get_first_day(date)) or (allocate_on_day == "Last Day" and date == get_last_day(date)) ), - "Quarterly": (allocate_on_day == "First Day" and date >= get_quarter_start(date)) - or (allocate_on_day == "Last Day" and date == get_quarter_ending(date)), - "Half-Yearly": (allocate_on_day == "First Day" and date >= get_semester_start(date)) - or (allocate_on_day == "Last Day" and date == get_semester_end(date)), + "Quarterly": ( + (allocate_on_day == "First Day" and date >= get_quarter_start(date)) + or (allocate_on_day == "Last Day" and date == get_quarter_ending(date)) + ), + "Half-Yearly": half_yearly_condition, "Yearly": ( (allocate_on_day == "First Day" and date >= get_year_start(date)) or (allocate_on_day == "Last Day" and date == get_year_ending(date)) ), } - return condition_map.get(earned_leave_frequency) diff --git a/hrms/hr/utils.py b/hrms/hr/utils.py index f6e667ca8f..cdd6f68df4 100644 --- a/hrms/hr/utils.py +++ b/hrms/hr/utils.py @@ -370,7 +370,11 @@ def allocate_earned_leaves(): else: date_of_joining = frappe.db.get_value("Employee", allocation.employee, "date_of_joining") allocation_date = get_expected_allocation_date_for_period( - e_leave_type.earned_leave_frequency, e_leave_type.allocate_on_day, today, date_of_joining + e_leave_type.earned_leave_frequency, + e_leave_type.allocate_on_day, + today, + date_of_joining, + effective_from=None, ) annual_allocation = get_annual_allocation_from_policy(allocation, e_leave_type) earned_leaves = calculate_upcoming_earned_leave(allocation, e_leave_type, date_of_joining) @@ -526,11 +530,14 @@ def get_monthly_earned_leave( return earned_leaves -def get_sub_period_start_and_end(date, frequency): +def get_sub_period_start_and_end(date, frequency, effective_from=None): + if frequency == "Half-Yearly" and effective_from: + return get_half_year_periods(date, effective_from) + return { "Monthly": (get_first_day(date), get_last_day(date)), "Quarterly": (get_quarter_start(date), get_quarter_ending(date)), - "Half-Yearly": (get_semester_start(date), get_semester_end(date)), + "Half-Yearly": (get_semester_start(date), get_semester_end(date)), # fallback only "Yearly": (get_year_start(date), get_year_ending(date)), }.get(frequency) @@ -605,11 +612,26 @@ def create_additional_leave_ledger_entry(allocation, leaves, date): allocation.create_leave_ledger_entry() -def get_expected_allocation_date_for_period(frequency, allocate_on_day, date, date_of_joining=None): +def get_expected_allocation_date_for_period( + frequency, allocate_on_day, date, date_of_joining=None, effective_from=None +): try: doj = date_of_joining.replace(month=date.month, year=date.year) - except ValueError: + except (ValueError, AttributeError): doj = datetime.date(date.year, date.month, calendar.monthrange(date.year, date.month)[1]) + + if frequency == "Half-Yearly" and effective_from: + period_start, period_end = get_half_year_periods(date, effective_from) + half_yearly_dates = { + "First Day": period_start, + "Last Day": period_end, + } + else: + half_yearly_dates = { + "First Day": get_semester_start(date), + "Last Day": get_semester_end(date), + } + return { "Monthly": { "First Day": get_first_day(date), @@ -620,7 +642,7 @@ def get_expected_allocation_date_for_period(frequency, allocate_on_day, date, da "First Day": get_quarter_start(date), "Last Day": get_quarter_ending(date), }, - "Half-Yearly": {"First Day": get_semester_start(date), "Last Day": get_semester_end(date)}, + "Half-Yearly": half_yearly_dates, "Yearly": {"First Day": get_year_start(date), "Last Day": get_year_ending(date)}, }[frequency][allocate_on_day] @@ -1050,3 +1072,29 @@ def get_semester_end(date): return get_year_ending(date) else: 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. + + 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 + """ + 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_year_start = add_months(effective_from, periods_elapsed * 6) + half_year_end = add_days(add_months(half_year_start, 6), -1) + + return half_year_start, half_year_end From 7f36a9a3c1741b9c0a276aa5cfd26d77844e81ad Mon Sep 17 00:00:00 2001 From: iamkhanraheel Date: Thu, 11 Jun 2026 18:41:40 +0530 Subject: [PATCH 13/14] test: add tests to assert earned leave schedule based on leave period and joining date --- .../leave_policy_assignment.py | 5 +- .../test_leave_policy_assignment.py | 98 +++++++++++++++++++ hrms/hr/doctype/leave_type/test_leave_type.py | 3 + 3 files changed, 104 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 9bed3d7b18..acdc0cf670 100644 --- a/hrms/hr/doctype/leave_policy_assignment/leave_policy_assignment.py +++ b/hrms/hr/doctype/leave_policy_assignment/leave_policy_assignment.py @@ -324,6 +324,7 @@ def get_earned_leave_schedule( leave_details.allocate_on_day, from_date, date_of_joining, + effective_from=self.effective_from, ) schedule = [] if new_leaves_allocated: @@ -412,9 +413,9 @@ def is_earned_leave_applicable_for_current_period( # For Half-Yearly, compute preiod relative to effective_from # instead of calendar year if earned_leave_frequency == "Half-Yearly" and effective_from: - from hrms.hr.utils import get_period_relative_half_year + from hrms.hr.utils import get_half_year_periods - period_start, period_end = get_period_relative_half_year(date, effective_from) + period_start, period_end = get_half_year_periods(date, effective_from) half_yearly_condition = (allocate_on_day == "First Day" and date >= period_start) or ( allocate_on_day == "Last Day" and date == period_end ) 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 9cf6bc17dd..4b4fcb83b3 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 @@ -321,3 +321,101 @@ def test_skip_zero_allocation_leaves(self): self.assertEqual(allocations[compoff.name]["new_leaves_allocated"], 3) self.assertEqual(allocations[annual.name]["new_leaves_allocated"], 3) self.assertNotIn(casual.name, allocations) + + def test_half_yearly_earned_leave_schedule_based_on_leave_period(self): + """ + Leave Period: 01-Apr-2026 to 31-Mar-2027 + Expected allocation dates: 30-Sep-2026, 31-Mar-2027 + NOT: 30-Jun-2026, 31-Dec-2026 + """ + leave_period = create_leave_period(getdate("2026-04-01"), getdate("2027-03-31"), "_Test Company") + leave_type = create_leave_type( + leave_type_name="_Test Half Yearly Earned Leave", + 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 at the start of the leave period + frappe.flags.current_date = getdate("2026-04-01") + + data = frappe._dict( + { + "assignment_based_on": "Leave Period", + "leave_policy": leave_policy.name, + "leave_period": leave_period.name, + } + ) + 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-09-30"), allocation_dates) + self.assertIn(getdate("2027-03-31"), allocation_dates) + self.assertNotIn(getdate("2026-06-30"), allocation_dates) + self.assertNotIn(getdate("2026-12-31"), 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(self): + """ + Employee joins: 01-Apr-2026 + Expected allocation dates: 30-Sep-2026, 31-Mar-2027 + """ + self.employee.date_of_joining = getdate("2026-04-01") + self.employee.save() + + leave_type = create_leave_type( + leave_type_name="_Test Half Yearly Earned Leave Joining Date", + 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() + + frappe.flags.current_date = getdate("2026-04-01") + + data = frappe._dict( + { + "assignment_based_on": "Joining Date", + "leave_policy": leave_policy.name, + "effective_from": self.employee.date_of_joining, + "effective_to": getdate("2027-03-31"), + } + ) + 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-09-30"), allocation_dates) + self.assertIn(getdate("2027-03-31"), allocation_dates) + self.assertNotIn(getdate("2026-06-30"), allocation_dates) + self.assertEqual(len(allocation_dates), 2) diff --git a/hrms/hr/doctype/leave_type/test_leave_type.py b/hrms/hr/doctype/leave_type/test_leave_type.py index 658a354c75..d4d8af5e42 100644 --- a/hrms/hr/doctype/leave_type/test_leave_type.py +++ b/hrms/hr/doctype/leave_type/test_leave_type.py @@ -34,6 +34,9 @@ def create_leave_type(**args): if leave_type.is_ppl: leave_type.fraction_of_daily_salary_per_leave = args.fraction_of_daily_salary_per_leave or 0.5 + if leave_type.is_earned_leave and args.earned_leave_frequency: + leave_type.earned_leave_frequency = args.earned_leave_frequency + leave_type.insert() return leave_type From d63ee9508b3625e85cc58b74f64fee6c6e4936b2 Mon Sep 17 00:00:00 2001 From: iamkhanraheel Date: Fri, 12 Jun 2026 16:56:55 +0530 Subject: [PATCH 14/14] test: remove current date flag --- .../leave_policy_assignment/test_leave_policy_assignment.py | 1 + 1 file changed, 1 insertion(+) 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 4b4fcb83b3..101e326246 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 @@ -176,6 +176,7 @@ def test_pro_rated_leave_allocation_for_custom_date_range(self): ).submit() today_date = getdate() + frappe.flags.pop("current_date", None) leave_policy_assignment = frappe.new_doc("Leave Policy Assignment") leave_policy_assignment.employee = self.employee.name