Merge pull request #29439 from ruchamahabal/leave-opening-balance

This commit is contained in:
Rucha Mahabal 2022-03-13 20:55:50 +05:30 committed by GitHub
commit 87d9863a71
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 830 additions and 200 deletions

View File

@ -2,6 +2,7 @@
# License: GNU General Public License v3. See license.txt
import unittest
from contextlib import contextmanager
from datetime import timedelta
import frappe
@ -30,3 +31,24 @@ def make_holiday_list(name, from_date=getdate()-timedelta(days=10), to_date=getd
"holidays" : holiday_dates
}).insert()
return doc
@contextmanager
def set_holiday_list(holiday_list, company_name):
"""
Context manager for setting holiday list in tests
"""
try:
company = frappe.get_doc('Company', company_name)
previous_holiday_list = company.default_holiday_list
company.default_holiday_list = holiday_list
company.save()
yield
finally:
# restore holiday list setup
company = frappe.get_doc('Company', company_name)
company.default_holiday_list = previous_holiday_list
company.save()

View File

@ -52,7 +52,7 @@ frappe.ui.form.on("Leave Application", {
make_dashboard: function(frm) {
var leave_details;
let lwps;
if (frm.doc.employee) {
if (frm.doc.employee && frm.doc.from_date) {
frappe.call({
method: "erpnext.hr.doctype.leave_application.leave_application.get_leave_details",
async: false,
@ -146,6 +146,7 @@ frappe.ui.form.on("Leave Application", {
},
to_date: function(frm) {
frm.trigger("make_dashboard");
frm.trigger("half_day_datepicker");
frm.trigger("calculate_total_days");
},

View File

@ -1,6 +1,7 @@
# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
from typing import Dict, Optional, Tuple
import frappe
from frappe import _
@ -35,6 +36,10 @@ class LeaveDayBlockedError(frappe.ValidationError): pass
class OverlapError(frappe.ValidationError): pass
class AttendanceAlreadyMarkedError(frappe.ValidationError): pass
class NotAnOptionalHoliday(frappe.ValidationError): pass
class InsufficientLeaveBalanceError(frappe.ValidationError):
pass
class LeaveAcrossAllocationsError(frappe.ValidationError):
pass
from frappe.model.document import Document
@ -135,21 +140,35 @@ class LeaveApplication(Document):
def validate_dates_across_allocation(self):
if frappe.db.get_value("Leave Type", self.leave_type, "allow_negative"):
return
def _get_leave_allocation_record(date):
allocation = frappe.db.sql("""select name from `tabLeave Allocation`
where employee=%s and leave_type=%s and docstatus=1
and %s between from_date and to_date""", (self.employee, self.leave_type, date))
return allocation and allocation[0][0]
alloc_on_from_date, alloc_on_to_date = self.get_allocation_based_on_application_dates()
if not (alloc_on_from_date or alloc_on_to_date):
frappe.throw(_("Application period cannot be outside leave allocation period"))
elif self.is_separate_ledger_entry_required(alloc_on_from_date, alloc_on_to_date):
frappe.throw(_("Application period cannot be across two allocation records"), exc=LeaveAcrossAllocationsError)
def get_allocation_based_on_application_dates(self) -> Tuple[Dict, Dict]:
"""Returns allocation name, from and to dates for application dates"""
def _get_leave_allocation_record(date):
LeaveAllocation = frappe.qb.DocType("Leave Allocation")
allocation = (
frappe.qb.from_(LeaveAllocation)
.select(LeaveAllocation.name, LeaveAllocation.from_date, LeaveAllocation.to_date)
.where(
(LeaveAllocation.employee == self.employee)
& (LeaveAllocation.leave_type == self.leave_type)
& (LeaveAllocation.docstatus == 1)
& ((date >= LeaveAllocation.from_date) & (date <= LeaveAllocation.to_date))
)
).run(as_dict=True)
return allocation and allocation[0]
allocation_based_on_from_date = _get_leave_allocation_record(self.from_date)
allocation_based_on_to_date = _get_leave_allocation_record(self.to_date)
if not (allocation_based_on_from_date or allocation_based_on_to_date):
frappe.throw(_("Application period cannot be outside leave allocation period"))
elif allocation_based_on_from_date != allocation_based_on_to_date:
frappe.throw(_("Application period cannot be across two allocation records"))
return allocation_based_on_from_date, allocation_based_on_to_date
def validate_back_dated_application(self):
future_allocation = frappe.db.sql("""select name, from_date from `tabLeave Allocation`
@ -261,15 +280,29 @@ class LeaveApplication(Document):
frappe.throw(_("The day(s) on which you are applying for leave are holidays. You need not apply for leave."))
if not is_lwp(self.leave_type):
self.leave_balance = get_leave_balance_on(self.employee, self.leave_type, self.from_date, self.to_date,
consider_all_leaves_in_the_allocation_period=True)
if self.status != "Rejected" and (self.leave_balance < self.total_leave_days or not self.leave_balance):
if frappe.db.get_value("Leave Type", self.leave_type, "allow_negative"):
frappe.msgprint(_("Note: There is not enough leave balance for Leave Type {0}")
.format(self.leave_type))
else:
frappe.throw(_("There is not enough leave balance for Leave Type {0}")
.format(self.leave_type))
leave_balance = get_leave_balance_on(self.employee, self.leave_type, self.from_date, self.to_date,
consider_all_leaves_in_the_allocation_period=True, for_consumption=True)
self.leave_balance = leave_balance.get("leave_balance")
leave_balance_for_consumption = leave_balance.get("leave_balance_for_consumption")
if self.status != "Rejected" and (leave_balance_for_consumption < self.total_leave_days or not leave_balance_for_consumption):
self.show_insufficient_balance_message(leave_balance_for_consumption)
def show_insufficient_balance_message(self, leave_balance_for_consumption: float) -> None:
alloc_on_from_date, alloc_on_to_date = self.get_allocation_based_on_application_dates()
if frappe.db.get_value("Leave Type", self.leave_type, "allow_negative"):
if leave_balance_for_consumption != self.leave_balance:
msg = _("Warning: Insufficient leave balance for Leave Type {0} in this allocation.").format(frappe.bold(self.leave_type))
msg += "<br><br>"
msg += _("Actual balances aren't available because the leave application spans over different leave allocations. You can still apply for leaves which would be compensated during the next allocation.")
else:
msg = _("Warning: Insufficient leave balance for Leave Type {0}.").format(frappe.bold(self.leave_type))
frappe.msgprint(msg, title=_("Warning"), indicator="orange")
else:
frappe.throw(_("Insufficient leave balance for Leave Type {0}").format(frappe.bold(self.leave_type)),
exc=InsufficientLeaveBalanceError, title=_("Insufficient Balance"))
def validate_leave_overlap(self):
if not self.name:
@ -426,54 +459,111 @@ class LeaveApplication(Document):
if self.status != 'Approved' and submit:
return
expiry_date = get_allocation_expiry(self.employee, self.leave_type,
expiry_date = get_allocation_expiry_for_cf_leaves(self.employee, self.leave_type,
self.to_date, self.from_date)
lwp = frappe.db.get_value("Leave Type", self.leave_type, "is_lwp")
if expiry_date:
self.create_ledger_entry_for_intermediate_allocation_expiry(expiry_date, submit, lwp)
else:
raise_exception = True
if frappe.flags.in_patch:
raise_exception=False
alloc_on_from_date, alloc_on_to_date = self.get_allocation_based_on_application_dates()
if self.is_separate_ledger_entry_required(alloc_on_from_date, alloc_on_to_date):
# required only if negative balance is allowed for leave type
# else will be stopped in validation itself
self.create_separate_ledger_entries(alloc_on_from_date, alloc_on_to_date, submit, lwp)
else:
raise_exception = False if frappe.flags.in_patch else True
args = dict(
leaves=self.total_leave_days * -1,
from_date=self.from_date,
to_date=self.to_date,
is_lwp=lwp,
holiday_list=get_holiday_list_for_employee(self.employee, raise_exception=raise_exception) or ''
)
create_leave_ledger_entry(self, args, submit)
args = dict(
leaves=self.total_leave_days * -1,
def is_separate_ledger_entry_required(self, alloc_on_from_date: Optional[Dict] = None, alloc_on_to_date: Optional[Dict] = None) -> bool:
"""Checks if application dates fall in separate allocations"""
if ((alloc_on_from_date and not alloc_on_to_date)
or (not alloc_on_from_date and alloc_on_to_date)
or (alloc_on_from_date and alloc_on_to_date and alloc_on_from_date.name != alloc_on_to_date.name)):
return True
return False
def create_separate_ledger_entries(self, alloc_on_from_date, alloc_on_to_date, submit, lwp):
"""Creates separate ledger entries for application period falling into separate allocations"""
# for creating separate ledger entries existing allocation periods should be consecutive
if submit and alloc_on_from_date and alloc_on_to_date and add_days(alloc_on_from_date.to_date, 1) != alloc_on_to_date.from_date:
frappe.throw(_("Leave Application period cannot be across two non-consecutive leave allocations {0} and {1}.").format(
get_link_to_form("Leave Allocation", alloc_on_from_date.name), get_link_to_form("Leave Allocation", alloc_on_to_date)))
raise_exception = False if frappe.flags.in_patch else True
if alloc_on_from_date:
first_alloc_end = alloc_on_from_date.to_date
second_alloc_start = add_days(alloc_on_from_date.to_date, 1)
else:
first_alloc_end = add_days(alloc_on_to_date.from_date, -1)
second_alloc_start = alloc_on_to_date.from_date
leaves_in_first_alloc = get_number_of_leave_days(self.employee, self.leave_type,
self.from_date, first_alloc_end, self.half_day, self.half_day_date)
leaves_in_second_alloc = get_number_of_leave_days(self.employee, self.leave_type,
second_alloc_start, self.to_date, self.half_day, self.half_day_date)
args = dict(
is_lwp=lwp,
holiday_list=get_holiday_list_for_employee(self.employee, raise_exception=raise_exception) or ''
)
if leaves_in_first_alloc:
args.update(dict(
from_date=self.from_date,
to_date=first_alloc_end,
leaves=leaves_in_first_alloc * -1
))
create_leave_ledger_entry(self, args, submit)
if leaves_in_second_alloc:
args.update(dict(
from_date=second_alloc_start,
to_date=self.to_date,
leaves=leaves_in_second_alloc * -1
))
create_leave_ledger_entry(self, args, submit)
def create_ledger_entry_for_intermediate_allocation_expiry(self, expiry_date, submit, lwp):
"""Splits leave application into two ledger entries to consider expiry of allocation"""
raise_exception = False if frappe.flags.in_patch else True
leaves = get_number_of_leave_days(self.employee, self.leave_type,
self.from_date, expiry_date, self.half_day, self.half_day_date)
if leaves:
args = dict(
from_date=self.from_date,
to_date=expiry_date,
leaves=leaves * -1,
is_lwp=lwp,
holiday_list=get_holiday_list_for_employee(self.employee, raise_exception=raise_exception) or ''
)
create_leave_ledger_entry(self, args, submit)
def create_ledger_entry_for_intermediate_allocation_expiry(self, expiry_date, submit, lwp):
''' splits leave application into two ledger entries to consider expiry of allocation '''
raise_exception = True
if frappe.flags.in_patch:
raise_exception=False
args = dict(
from_date=self.from_date,
to_date=expiry_date,
leaves=(date_diff(expiry_date, self.from_date) + 1) * -1,
is_lwp=lwp,
holiday_list=get_holiday_list_for_employee(self.employee, raise_exception=raise_exception) or ''
)
create_leave_ledger_entry(self, args, submit)
if getdate(expiry_date) != getdate(self.to_date):
start_date = add_days(expiry_date, 1)
args.update(dict(
from_date=start_date,
to_date=self.to_date,
leaves=date_diff(self.to_date, expiry_date) * -1
))
create_leave_ledger_entry(self, args, submit)
leaves = get_number_of_leave_days(self.employee, self.leave_type,
start_date, self.to_date, self.half_day, self.half_day_date)
if leaves:
args.update(dict(
from_date=start_date,
to_date=self.to_date,
leaves=leaves * -1
))
create_leave_ledger_entry(self, args, submit)
def get_allocation_expiry(employee, leave_type, to_date, from_date):
def get_allocation_expiry_for_cf_leaves(employee: str, leave_type: str, to_date: str, from_date: str) -> str:
''' Returns expiry of carry forward allocation in leave ledger entry '''
expiry = frappe.get_all("Leave Ledger Entry",
filters={
@ -481,12 +571,17 @@ def get_allocation_expiry(employee, leave_type, to_date, from_date):
'leave_type': leave_type,
'is_carry_forward': 1,
'transaction_type': 'Leave Allocation',
'to_date': ['between', (from_date, to_date)]
'to_date': ['between', (from_date, to_date)],
'docstatus': 1
},fields=['to_date'])
return expiry[0]['to_date'] if expiry else None
return expiry[0]['to_date'] if expiry else ''
@frappe.whitelist()
def get_number_of_leave_days(employee, leave_type, from_date, to_date, half_day = None, half_day_date = None, holiday_list = None):
def get_number_of_leave_days(employee: str, leave_type: str, from_date: str, to_date: str, half_day: Optional[int] = None,
half_day_date: Optional[str] = None, holiday_list: Optional[str] = None) -> float:
"""Returns number of leave days between 2 dates after considering half day and holidays
(Based on the include_holiday setting in Leave Type)"""
number_of_days = 0
if cint(half_day) == 1:
if from_date == to_date:
@ -502,6 +597,7 @@ def get_number_of_leave_days(employee, leave_type, from_date, to_date, half_day
number_of_days = flt(number_of_days) - flt(get_holidays(employee, from_date, to_date, holiday_list=holiday_list))
return number_of_days
@frappe.whitelist()
def get_leave_details(employee, date):
allocation_records = get_leave_allocation_records(employee, date)
@ -514,6 +610,7 @@ def get_leave_details(employee, date):
'to_date': ('>=', date),
'employee': employee,
'leave_type': allocation.leave_type,
'docstatus': 1
}, 'SUM(total_leaves_allocated)') or 0
remaining_leaves = get_leave_balance_on(employee, d, date, to_date = allocation.to_date,
@ -521,29 +618,28 @@ def get_leave_details(employee, date):
end_date = allocation.to_date
leaves_taken = get_leaves_for_period(employee, d, allocation.from_date, end_date) * -1
leaves_pending = get_pending_leaves_for_period(employee, d, allocation.from_date, end_date)
leaves_pending = get_leaves_pending_approval_for_period(employee, d, allocation.from_date, end_date)
leave_allocation[d] = {
"total_leaves": total_allocated_leaves,
"expired_leaves": total_allocated_leaves - (remaining_leaves + leaves_taken),
"leaves_taken": leaves_taken,
"pending_leaves": leaves_pending,
"leaves_pending_approval": leaves_pending,
"remaining_leaves": remaining_leaves}
#is used in set query
lwps = frappe.get_list("Leave Type", filters = {"is_lwp": 1})
lwps = [lwp.name for lwp in lwps]
lwp = frappe.get_list("Leave Type", filters={"is_lwp": 1}, pluck="name")
ret = {
'leave_allocation': leave_allocation,
'leave_approver': get_leave_approver(employee),
'lwps': lwps
return {
"leave_allocation": leave_allocation,
"leave_approver": get_leave_approver(employee),
"lwps": lwp
}
return ret
@frappe.whitelist()
def get_leave_balance_on(employee, leave_type, date, to_date=None, consider_all_leaves_in_the_allocation_period=False):
def get_leave_balance_on(employee: str, leave_type: str, date: str, to_date: str = None,
consider_all_leaves_in_the_allocation_period: bool = False, for_consumption: bool = False):
'''
Returns leave balance till date
:param employee: employee name
@ -551,6 +647,11 @@ def get_leave_balance_on(employee, leave_type, date, to_date=None, consider_all_
:param date: date to check balance on
:param to_date: future date to check for allocation expiry
:param consider_all_leaves_in_the_allocation_period: consider all leaves taken till the allocation end date
:param for_consumption: flag to check if leave balance is required for consumption or display
eg: employee has leave balance = 10 but allocation is expiring in 1 day so employee can only consume 1 leave
in this case leave_balance = 10 but leave_balance_for_consumption = 1
if True, returns a dict eg: {'leave_balance': 10, 'leave_balance_for_consumption': 1}
else, returns leave_balance (in this case 10)
'''
if not to_date:
@ -560,11 +661,17 @@ def get_leave_balance_on(employee, leave_type, date, to_date=None, consider_all_
allocation = allocation_records.get(leave_type, frappe._dict())
end_date = allocation.to_date if consider_all_leaves_in_the_allocation_period else date
expiry = get_allocation_expiry(employee, leave_type, to_date, date)
cf_expiry = get_allocation_expiry_for_cf_leaves(employee, leave_type, to_date, date)
leaves_taken = get_leaves_for_period(employee, leave_type, allocation.from_date, end_date)
return get_remaining_leaves(allocation, leaves_taken, date, expiry)
remaining_leaves = get_remaining_leaves(allocation, leaves_taken, date, cf_expiry)
if for_consumption:
return remaining_leaves
else:
return remaining_leaves.get('leave_balance')
def get_leave_allocation_records(employee, date, leave_type=None):
"""Returns the total allocated leaves and carry forwarded leaves based on ledger entries"""
@ -613,8 +720,9 @@ def get_leave_allocation_records(employee, date, leave_type=None):
}))
return allocated_leaves
def get_pending_leaves_for_period(employee, leave_type, from_date, to_date):
''' Returns leaves that are pending approval '''
def get_leaves_pending_approval_for_period(employee: str, leave_type: str, from_date: str, to_date: str) -> float:
''' Returns leaves that are pending for approval '''
leaves = frappe.get_all("Leave Application",
filters={
"employee": employee,
@ -627,38 +735,46 @@ def get_pending_leaves_for_period(employee, leave_type, from_date, to_date):
}, fields=['SUM(total_leave_days) as leaves'])[0]
return leaves['leaves'] if leaves['leaves'] else 0.0
def get_remaining_leaves(allocation, leaves_taken, date, expiry):
''' Returns minimum leaves remaining after comparing with remaining days for allocation expiry '''
def _get_remaining_leaves(remaining_leaves, end_date):
def get_remaining_leaves(allocation: Dict, leaves_taken: float, date: str, cf_expiry: str) -> Dict[str, float]:
'''Returns a dict of leave_balance and leave_balance_for_consumption
leave_balance returns the available leave balance
leave_balance_for_consumption returns the minimum leaves remaining after comparing with remaining days for allocation expiry
'''
def _get_remaining_leaves(remaining_leaves, end_date):
''' Returns minimum leaves remaining after comparing with remaining days for allocation expiry '''
if remaining_leaves > 0:
remaining_days = date_diff(end_date, date) + 1
remaining_leaves = min(remaining_days, remaining_leaves)
return remaining_leaves
total_leaves = flt(allocation.total_leaves_allocated) + flt(leaves_taken)
leave_balance = leave_balance_for_consumption = flt(allocation.total_leaves_allocated) + flt(leaves_taken)
if expiry and allocation.unused_leaves:
remaining_leaves = flt(allocation.unused_leaves) + flt(leaves_taken)
remaining_leaves = _get_remaining_leaves(remaining_leaves, expiry)
# balance for carry forwarded leaves
if cf_expiry and allocation.unused_leaves:
cf_leaves = flt(allocation.unused_leaves) + flt(leaves_taken)
remaining_cf_leaves = _get_remaining_leaves(cf_leaves, cf_expiry)
total_leaves = flt(allocation.new_leaves_allocated) + flt(remaining_leaves)
leave_balance = flt(allocation.new_leaves_allocated) + flt(cf_leaves)
leave_balance_for_consumption = flt(allocation.new_leaves_allocated) + flt(remaining_cf_leaves)
return _get_remaining_leaves(total_leaves, allocation.to_date)
remaining_leaves = _get_remaining_leaves(leave_balance_for_consumption, allocation.to_date)
return frappe._dict(leave_balance=leave_balance, leave_balance_for_consumption=remaining_leaves)
def get_leaves_for_period(employee, leave_type, from_date, to_date, do_not_skip_expired_leaves=False):
def get_leaves_for_period(employee: str, leave_type: str, from_date: str, to_date: str, skip_expired_leaves: bool = True) -> float:
leave_entries = get_leave_entries(employee, leave_type, from_date, to_date)
leave_days = 0
for leave_entry in leave_entries:
inclusive_period = leave_entry.from_date >= getdate(from_date) and leave_entry.to_date <= getdate(to_date)
if inclusive_period and leave_entry.transaction_type == 'Leave Encashment':
if inclusive_period and leave_entry.transaction_type == 'Leave Encashment':
leave_days += leave_entry.leaves
elif inclusive_period and leave_entry.transaction_type == 'Leave Allocation' and leave_entry.is_expired \
and (do_not_skip_expired_leaves or not skip_expiry_leaves(leave_entry, to_date)):
and not skip_expired_leaves:
leave_days += leave_entry.leaves
elif leave_entry.transaction_type == 'Leave Application':
@ -680,11 +796,6 @@ def get_leaves_for_period(employee, leave_type, from_date, to_date, do_not_skip_
return leave_days
def skip_expiry_leaves(leave_entry, date):
''' Checks whether the expired leaves coincide with the to_date of leave balance check.
This allows backdated leave entry creation for non carry forwarded allocation '''
end_date = frappe.db.get_value("Leave Allocation", {'name': leave_entry.transaction_name}, ['to_date'])
return True if end_date == date and not leave_entry.is_carry_forward else False
def get_leave_entries(employee, leave_type, from_date, to_date):
''' Returns leave entries between from_date and to_date. '''
@ -707,6 +818,7 @@ def get_leave_entries(employee, leave_type, from_date, to_date):
"leave_type": leave_type
}, as_dict=1)
@frappe.whitelist()
def get_holidays(employee, from_date, to_date, holiday_list = None):
'''get holidays between two dates for the given employee'''
@ -723,6 +835,7 @@ def is_lwp(leave_type):
lwp = frappe.db.sql("select is_lwp from `tabLeave Type` where name = %s", leave_type)
return lwp and cint(lwp[0][0]) or 0
@frappe.whitelist()
def get_events(start, end, filters=None):
from frappe.desk.reportview import get_filters_cond
@ -751,6 +864,7 @@ def get_events(start, end, filters=None):
return events
def add_department_leaves(events, start, end, employee, company):
department = frappe.db.get_value("Employee", employee, "department")
@ -831,6 +945,7 @@ def add_block_dates(events, start, end, employee, company):
})
cnt+=1
def add_holidays(events, start, end, employee, company):
applicable_holiday_list = get_holiday_list_for_employee(employee, company)
if not applicable_holiday_list:
@ -847,6 +962,7 @@ def add_holidays(events, start, end, employee, company):
"name": holiday.name
})
@frappe.whitelist()
def get_mandatory_approval(doctype):
mandatory = ""
@ -859,6 +975,7 @@ def get_mandatory_approval(doctype):
return mandatory
def get_approved_leaves_for_period(employee, leave_type, from_date, to_date):
query = """
select employee, leave_type, from_date, to_date, total_leave_days
@ -894,6 +1011,7 @@ def get_approved_leaves_for_period(employee, leave_type, from_date, to_date):
return leave_days
@frappe.whitelist()
def get_leave_approver(employee):
leave_approver, department = frappe.db.get_value("Employee",

View File

@ -4,11 +4,11 @@
<thead>
<tr>
<th style="width: 16%">{{ __("Leave Type") }}</th>
<th style="width: 16%" class="text-right">{{ __("Total Allocated Leave") }}</th>
<th style="width: 16%" class="text-right">{{ __("Expired Leave") }}</th>
<th style="width: 16%" class="text-right">{{ __("Used Leave") }}</th>
<th style="width: 16%" class="text-right">{{ __("Pending Leave") }}</th>
<th style="width: 16%" class="text-right">{{ __("Available Leave") }}</th>
<th style="width: 16%" class="text-right">{{ __("Total Allocated Leave(s)") }}</th>
<th style="width: 16%" class="text-right">{{ __("Expired Leave(s)") }}</th>
<th style="width: 16%" class="text-right">{{ __("Used Leave(s)") }}</th>
<th style="width: 16%" class="text-right">{{ __("Leave(s) Pending Approval") }}</th>
<th style="width: 16%" class="text-right">{{ __("Available Leave(s)") }}</th>
</tr>
</thead>
<tbody>
@ -18,7 +18,7 @@
<td class="text-right"> {%= value["total_leaves"] %} </td>
<td class="text-right"> {%= value["expired_leaves"] %} </td>
<td class="text-right"> {%= value["leaves_taken"] %} </td>
<td class="text-right"> {%= value["pending_leaves"] %} </td>
<td class="text-right"> {%= value["leaves_pending_approval"] %} </td>
<td class="text-right"> {%= value["remaining_leaves"] %} </td>
</tr>
{% } %}

View File

@ -17,12 +17,16 @@ from frappe.utils import (
)
from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.hr.doctype.holiday_list.test_holiday_list import set_holiday_list
from erpnext.hr.doctype.leave_allocation.test_leave_allocation import create_leave_allocation
from erpnext.hr.doctype.leave_application.leave_application import (
InsufficientLeaveBalanceError,
LeaveAcrossAllocationsError,
LeaveDayBlockedError,
NotAnOptionalHoliday,
OverlapError,
get_leave_balance_on,
get_leave_details,
)
from erpnext.hr.doctype.leave_policy_assignment.leave_policy_assignment import (
create_assignment_for_multiple_employees,
@ -33,7 +37,7 @@ from erpnext.payroll.doctype.salary_slip.test_salary_slip import (
make_leave_application,
)
test_dependencies = ["Leave Allocation", "Leave Block List", "Employee"]
test_dependencies = ["Leave Type", "Leave Allocation", "Leave Block List", "Employee"]
_test_records = [
{
@ -72,15 +76,24 @@ _test_records = [
class TestLeaveApplication(unittest.TestCase):
def setUp(self):
for dt in ["Leave Application", "Leave Allocation", "Salary Slip", "Leave Ledger Entry"]:
frappe.db.sql("DELETE FROM `tab%s`" % dt) #nosec
frappe.db.delete(dt)
frappe.set_user("Administrator")
set_leave_approver()
frappe.db.sql("delete from tabAttendance where employee='_T-Employee-00001'")
frappe.db.delete("Attendance", {"employee": "_T-Employee-00001"})
self.holiday_list = make_holiday_list()
if not frappe.db.exists("Leave Type", "_Test Leave Type"):
frappe.get_doc(dict(
leave_type_name="_Test Leave Type",
doctype="Leave Type",
include_holiday=True
)).insert()
def tearDown(self):
frappe.db.rollback()
frappe.set_user("Administrator")
def _clear_roles(self):
frappe.db.sql("""delete from `tabHas Role` where parent in
@ -95,6 +108,132 @@ class TestLeaveApplication(unittest.TestCase):
application.to_date = "2013-01-05"
return application
@set_holiday_list('Salary Slip Test Holiday List', '_Test Company')
def test_validate_application_across_allocations(self):
# Test validation for application dates when negative balance is disabled
frappe.delete_doc_if_exists("Leave Type", "Test Leave Validation", force=1)
leave_type = frappe.get_doc(dict(
leave_type_name="Test Leave Validation",
doctype="Leave Type",
allow_negative=False
)).insert()
employee = get_employee()
date = getdate()
first_sunday = get_first_sunday(self.holiday_list, for_date=get_year_start(date))
leave_application = frappe.get_doc(dict(
doctype='Leave Application',
employee=employee.name,
leave_type=leave_type.name,
from_date=add_days(first_sunday, 1),
to_date=add_days(first_sunday, 4),
company="_Test Company",
status="Approved",
leave_approver = 'test@example.com'
))
# Application period cannot be outside leave allocation period
self.assertRaises(frappe.ValidationError, leave_application.insert)
make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date))
leave_application = frappe.get_doc(dict(
doctype='Leave Application',
employee=employee.name,
leave_type=leave_type.name,
from_date=add_days(first_sunday, -10),
to_date=add_days(first_sunday, 1),
company="_Test Company",
status="Approved",
leave_approver = 'test@example.com'
))
# Application period cannot be across two allocation records
self.assertRaises(LeaveAcrossAllocationsError, leave_application.insert)
@set_holiday_list('Salary Slip Test Holiday List', '_Test Company')
def test_insufficient_leave_balance_validation(self):
# CASE 1: Validation when allow negative is disabled
frappe.delete_doc_if_exists("Leave Type", "Test Leave Validation", force=1)
leave_type = frappe.get_doc(dict(
leave_type_name="Test Leave Validation",
doctype="Leave Type",
allow_negative=False
)).insert()
employee = get_employee()
date = getdate()
first_sunday = get_first_sunday(self.holiday_list, for_date=get_year_start(date))
# allocate 2 leaves, apply for more
make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date), leaves=2)
leave_application = frappe.get_doc(dict(
doctype='Leave Application',
employee=employee.name,
leave_type=leave_type.name,
from_date=add_days(first_sunday, 1),
to_date=add_days(first_sunday, 3),
company="_Test Company",
status="Approved",
leave_approver = 'test@example.com'
))
self.assertRaises(InsufficientLeaveBalanceError, leave_application.insert)
# CASE 2: Allows creating application with a warning message when allow negative is enabled
frappe.db.set_value("Leave Type", "Test Leave Validation", "allow_negative", True)
make_leave_application(employee.name, add_days(first_sunday, 1), add_days(first_sunday, 3), leave_type.name)
@set_holiday_list('Salary Slip Test Holiday List', '_Test Company')
def test_separate_leave_ledger_entry_for_boundary_applications(self):
# When application falls in 2 different allocations and Allow Negative is enabled
# creates separate leave ledger entries
frappe.delete_doc_if_exists("Leave Type", "Test Leave Validation", force=1)
leave_type = frappe.get_doc(dict(
leave_type_name="Test Leave Validation",
doctype="Leave Type",
allow_negative=True
)).insert()
employee = get_employee()
date = getdate()
year_start = getdate(get_year_start(date))
year_end = getdate(get_year_ending(date))
make_allocation_record(leave_type=leave_type.name, from_date=year_start, to_date=year_end)
# application across allocations
# CASE 1: from date has no allocation, to date has an allocation / both dates have allocation
application = make_leave_application(employee.name, add_days(year_start, -10), add_days(year_start, 3), leave_type.name)
# 2 separate leave ledger entries
ledgers = frappe.db.get_all("Leave Ledger Entry", {
"transaction_type": "Leave Application",
"transaction_name": application.name
}, ["leaves", "from_date", "to_date"], order_by="from_date")
self.assertEqual(len(ledgers), 2)
self.assertEqual(ledgers[0].from_date, application.from_date)
self.assertEqual(ledgers[0].to_date, add_days(year_start, -1))
self.assertEqual(ledgers[1].from_date, year_start)
self.assertEqual(ledgers[1].to_date, application.to_date)
# CASE 2: from date has an allocation, to date has no allocation
application = make_leave_application(employee.name, add_days(year_end, -3), add_days(year_end, 5), leave_type.name)
# 2 separate leave ledger entries
ledgers = frappe.db.get_all("Leave Ledger Entry", {
"transaction_type": "Leave Application",
"transaction_name": application.name
}, ["leaves", "from_date", "to_date"], order_by="from_date")
self.assertEqual(len(ledgers), 2)
self.assertEqual(ledgers[0].from_date, application.from_date)
self.assertEqual(ledgers[0].to_date, year_end)
self.assertEqual(ledgers[1].from_date, add_days(year_end, 1))
self.assertEqual(ledgers[1].to_date, application.to_date)
def test_overwrite_attendance(self):
'''check attendance is automatically created on leave approval'''
make_allocation_record()
@ -119,6 +258,7 @@ class TestLeaveApplication(unittest.TestCase):
for d in ('2018-01-01', '2018-01-02', '2018-01-03'):
self.assertTrue(getdate(d) in dates)
@set_holiday_list('Salary Slip Test Holiday List', '_Test Company')
def test_attendance_for_include_holidays(self):
# Case 1: leave type with 'Include holidays within leaves as leaves' enabled
frappe.delete_doc_if_exists("Leave Type", "Test Include Holidays", force=1)
@ -131,12 +271,8 @@ class TestLeaveApplication(unittest.TestCase):
date = getdate()
make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date))
holiday_list = make_holiday_list()
employee = get_employee()
original_holiday_list = employee.holiday_list
frappe.db.set_value("Employee", employee.name, "holiday_list", holiday_list)
first_sunday = get_first_sunday(holiday_list)
first_sunday = get_first_sunday(self.holiday_list)
leave_application = make_leave_application(employee.name, first_sunday, add_days(first_sunday, 3), leave_type.name)
leave_application.reload()
@ -145,8 +281,7 @@ class TestLeaveApplication(unittest.TestCase):
leave_application.cancel()
frappe.db.set_value("Employee", employee.name, "holiday_list", original_holiday_list)
@set_holiday_list('Salary Slip Test Holiday List', '_Test Company')
def test_attendance_update_for_exclude_holidays(self):
# Case 2: leave type with 'Include holidays within leaves as leaves' disabled
frappe.delete_doc_if_exists("Leave Type", "Test Do Not Include Holidays", force=1)
@ -159,11 +294,8 @@ class TestLeaveApplication(unittest.TestCase):
date = getdate()
make_allocation_record(leave_type=leave_type.name, from_date=get_year_start(date), to_date=get_year_ending(date))
holiday_list = make_holiday_list()
employee = get_employee()
original_holiday_list = employee.holiday_list
frappe.db.set_value("Employee", employee.name, "holiday_list", holiday_list)
first_sunday = get_first_sunday(holiday_list)
first_sunday = get_first_sunday(self.holiday_list)
# already marked attendance on a holiday should be deleted in this case
config = {
@ -194,8 +326,6 @@ class TestLeaveApplication(unittest.TestCase):
# attendance on non-holiday updated
self.assertEqual(frappe.db.get_value("Attendance", attendance.name, "status"), "On Leave")
frappe.db.set_value("Employee", employee.name, "holiday_list", original_holiday_list)
def test_block_list(self):
self._clear_roles()
@ -327,17 +457,14 @@ class TestLeaveApplication(unittest.TestCase):
application.half_day_date = "2013-01-05"
application.insert()
@set_holiday_list('Salary Slip Test Holiday List', '_Test Company')
def test_optional_leave(self):
leave_period = get_leave_period()
today = nowdate()
holiday_list = 'Test Holiday List for Optional Holiday'
employee = get_employee()
default_holiday_list = make_holiday_list()
original_holiday_list = employee.holiday_list
frappe.db.set_value("Employee", employee.name, "holiday_list", default_holiday_list)
first_sunday = get_first_sunday(default_holiday_list)
first_sunday = get_first_sunday(self.holiday_list)
optional_leave_date = add_days(first_sunday, 1)
if not frappe.db.exists('Holiday List', holiday_list):
@ -386,8 +513,6 @@ class TestLeaveApplication(unittest.TestCase):
# check leave balance is reduced
self.assertEqual(get_leave_balance_on(employee.name, leave_type, optional_leave_date), 9)
frappe.db.set_value("Employee", employee.name, "holiday_list", original_holiday_list)
def test_leaves_allowed(self):
employee = get_employee()
leave_period = get_leave_period()
@ -513,11 +638,13 @@ class TestLeaveApplication(unittest.TestCase):
leave_type_name="_Test_CF_leave_expiry",
is_carry_forward=1,
expire_carry_forwarded_leaves_after_days=90)
leave_type.submit()
leave_type.insert()
create_carry_forwarded_allocation(employee, leave_type)
details = get_leave_balance_on(employee.name, leave_type.name, nowdate(), add_days(nowdate(), 8), for_consumption=True)
self.assertEqual(get_leave_balance_on(employee.name, leave_type.name, nowdate(), add_days(nowdate(), 8)), 21)
self.assertEqual(details.leave_balance_for_consumption, 21)
self.assertEqual(details.leave_balance, 30)
def test_earned_leaves_creation(self):
@ -571,7 +698,14 @@ class TestLeaveApplication(unittest.TestCase):
# test to not consider current leave in leave balance while submitting
def test_current_leave_on_submit(self):
employee = get_employee()
leave_type = 'Sick leave'
leave_type = 'Sick Leave'
if not frappe.db.exists('Leave Type', leave_type):
frappe.get_doc(dict(
leave_type_name=leave_type,
doctype='Leave Type'
)).insert()
allocation = frappe.get_doc(dict(
doctype = 'Leave Allocation',
employee = employee.name,
@ -714,6 +848,35 @@ class TestLeaveApplication(unittest.TestCase):
employee.leave_approver = ""
employee.save()
@set_holiday_list('Salary Slip Test Holiday List', '_Test Company')
def test_get_leave_details_for_dashboard(self):
employee = get_employee()
date = getdate()
year_start = getdate(get_year_start(date))
year_end = getdate(get_year_ending(date))
# ALLOCATION = 30
allocation = make_allocation_record(employee=employee.name, from_date=year_start, to_date=year_end)
# USED LEAVES = 4
first_sunday = get_first_sunday(self.holiday_list)
leave_application = make_leave_application(employee.name, add_days(first_sunday, 1), add_days(first_sunday, 4), '_Test Leave Type')
leave_application.reload()
# LEAVES PENDING APPROVAL = 1
leave_application = make_leave_application(employee.name, add_days(first_sunday, 5), add_days(first_sunday, 5),
'_Test Leave Type', submit=False)
leave_application.status = 'Open'
leave_application.save()
details = get_leave_details(employee.name, allocation.from_date)
leave_allocation = details['leave_allocation']['_Test Leave Type']
self.assertEqual(leave_allocation['total_leaves'], 30)
self.assertEqual(leave_allocation['leaves_taken'], 4)
self.assertEqual(leave_allocation['expired_leaves'], 0)
self.assertEqual(leave_allocation['leaves_pending_approval'], 1)
self.assertEqual(leave_allocation['remaining_leaves'], 26)
def create_carry_forwarded_allocation(employee, leave_type):
# initial leave allocation
@ -735,19 +898,22 @@ def create_carry_forwarded_allocation(employee, leave_type):
carry_forward=1)
leave_allocation.submit()
def make_allocation_record(employee=None, leave_type=None, from_date=None, to_date=None):
def make_allocation_record(employee=None, leave_type=None, from_date=None, to_date=None, carry_forward=False, leaves=None):
allocation = frappe.get_doc({
"doctype": "Leave Allocation",
"employee": employee or "_T-Employee-00001",
"leave_type": leave_type or "_Test Leave Type",
"from_date": from_date or "2013-01-01",
"to_date": to_date or "2019-12-31",
"new_leaves_allocated": 30
"new_leaves_allocated": leaves or 30,
"carry_forward": carry_forward
})
allocation.insert(ignore_permissions=True)
allocation.submit()
return allocation
def get_employee():
return frappe.get_doc("Employee", "_T-Employee-00001")

View File

@ -171,7 +171,7 @@ def expire_carried_forward_allocation(allocation):
''' Expires remaining leaves in the on carried forward allocation '''
from erpnext.hr.doctype.leave_application.leave_application import get_leaves_for_period
leaves_taken = get_leaves_for_period(allocation.employee, allocation.leave_type,
allocation.from_date, allocation.to_date, do_not_skip_expired_leaves=True)
allocation.from_date, allocation.to_date, skip_expired_leaves=False)
leaves = flt(allocation.leaves) + flt(leaves_taken)
# allow expired leaves entry to be created

View File

@ -4,7 +4,7 @@
import unittest
import frappe
from frappe.utils import add_months, get_first_day, get_last_day, getdate
from frappe.utils import add_days, add_months, get_first_day, get_last_day, getdate
from erpnext.hr.doctype.leave_application.test_leave_application import (
get_employee,
@ -95,9 +95,12 @@ class TestLeavePolicyAssignment(unittest.TestCase):
"leave_policy": leave_policy.name,
"leave_period": leave_period.name
}
# second last day of the month
# leaves allocated should be 0 since it is an earned leave and allocation happens via scheduler based on set frequency
frappe.flags.current_date = add_days(get_last_day(getdate()), -1)
leave_policy_assignments = create_assignment_for_multiple_employees([self.employee.name], frappe._dict(data))
# leaves allocated should be 0 since it is an earned leave and allocation happens via scheduler based on set frequency
leaves_allocated = frappe.db.get_value("Leave Allocation", {
"leave_policy_assignment": leave_policy_assignments[0]
}, "total_leaves_allocated")

View File

@ -3,18 +3,21 @@
from itertools import groupby
from typing import Dict, List, Optional, Tuple
import frappe
from frappe import _
from frappe.utils import add_days
from frappe.utils import add_days, getdate
from erpnext.hr.doctype.leave_allocation.leave_allocation import get_previous_allocation
from erpnext.hr.doctype.leave_application.leave_application import (
get_leave_balance_on,
get_leaves_for_period,
)
Filters = frappe._dict
def execute(filters=None):
def execute(filters: Optional[Filters] = None) -> Tuple:
if filters.to_date <= filters.from_date:
frappe.throw(_('"From Date" can not be greater than or equal to "To Date"'))
@ -23,8 +26,9 @@ def execute(filters=None):
charts = get_chart_data(data)
return columns, data, None, charts
def get_columns():
columns = [{
def get_columns() -> List[Dict]:
return [{
'label': _('Leave Type'),
'fieldtype': 'Link',
'fieldname': 'leave_type',
@ -46,32 +50,31 @@ def get_columns():
'label': _('Opening Balance'),
'fieldtype': 'float',
'fieldname': 'opening_balance',
'width': 130,
'width': 150,
}, {
'label': _('Leave Allocated'),
'label': _('New Leave(s) Allocated'),
'fieldtype': 'float',
'fieldname': 'leaves_allocated',
'width': 130,
'width': 200,
}, {
'label': _('Leave Taken'),
'label': _('Leave(s) Taken'),
'fieldtype': 'float',
'fieldname': 'leaves_taken',
'width': 130,
'width': 150,
}, {
'label': _('Leave Expired'),
'label': _('Leave(s) Expired'),
'fieldtype': 'float',
'fieldname': 'leaves_expired',
'width': 130,
'width': 150,
}, {
'label': _('Closing Balance'),
'fieldtype': 'float',
'fieldname': 'closing_balance',
'width': 130,
'width': 150,
}]
return columns
def get_data(filters):
def get_data(filters: Filters) -> List:
leave_types = frappe.db.get_list('Leave Type', pluck='name', order_by='name')
conditions = get_conditions(filters)
@ -102,19 +105,18 @@ def get_data(filters):
or ("HR Manager" in frappe.get_roles(user)):
if len(active_employees) > 1:
row = frappe._dict()
row.employee = employee.name,
row.employee = employee.name
row.employee_name = employee.employee_name
leaves_taken = get_leaves_for_period(employee.name, leave_type,
filters.from_date, filters.to_date) * -1
new_allocation, expired_leaves = get_allocated_and_expired_leaves(filters.from_date, filters.to_date, employee.name, leave_type)
opening = get_leave_balance_on(employee.name, leave_type, add_days(filters.from_date, -1)) #allocation boundary condition
new_allocation, expired_leaves, carry_forwarded_leaves = get_allocated_and_expired_leaves(
filters.from_date, filters.to_date, employee.name, leave_type)
opening = get_opening_balance(employee.name, leave_type, filters, carry_forwarded_leaves)
row.leaves_allocated = new_allocation
row.leaves_expired = expired_leaves - leaves_taken if expired_leaves - leaves_taken > 0 else 0
row.leaves_expired = expired_leaves
row.opening_balance = opening
row.leaves_taken = leaves_taken
@ -125,7 +127,26 @@ def get_data(filters):
return data
def get_conditions(filters):
def get_opening_balance(employee: str, leave_type: str, filters: Filters, carry_forwarded_leaves: float) -> float:
# allocation boundary condition
# opening balance is the closing leave balance 1 day before the filter start date
opening_balance_date = add_days(filters.from_date, -1)
allocation = get_previous_allocation(filters.from_date, leave_type, employee)
if allocation and allocation.get("to_date") and opening_balance_date and \
getdate(allocation.get("to_date")) == getdate(opening_balance_date):
# if opening balance date is same as the previous allocation's expiry
# then opening balance should only consider carry forwarded leaves
opening_balance = carry_forwarded_leaves
else:
# else directly get leave balance on the previous day
opening_balance = get_leave_balance_on(employee, leave_type, opening_balance_date)
return opening_balance
def get_conditions(filters: Filters) -> Dict:
conditions={
'status': 'Active',
}
@ -140,29 +161,26 @@ def get_conditions(filters):
return conditions
def get_department_leave_approver_map(department=None):
def get_department_leave_approver_map(department: Optional[str] = None):
# get current department and all its child
department_list = frappe.get_list('Department',
filters={
'disabled': 0
},
or_filters={
'name': department,
'parent_department': department
},
fields=['name'],
pluck='name'
)
filters={'disabled': 0},
or_filters={
'name': department,
'parent_department': department
},
pluck='name'
)
# retrieve approvers list from current department and from its subsequent child departments
approver_list = frappe.get_all('Department Approver',
filters={
'parentfield': 'leave_approvers',
'parent': ('in', department_list)
},
fields=['parent', 'approver'],
as_list=1
)
filters={
'parentfield': 'leave_approvers',
'parent': ('in', department_list)
},
fields=['parent', 'approver'],
as_list=True
)
approvers = {}
@ -171,41 +189,61 @@ def get_department_leave_approver_map(department=None):
return approvers
def get_allocated_and_expired_leaves(from_date, to_date, employee, leave_type):
from frappe.utils import getdate
def get_allocated_and_expired_leaves(from_date: str, to_date: str, employee: str, leave_type: str) -> Tuple[float, float, float]:
new_allocation = 0
expired_leaves = 0
carry_forwarded_leaves = 0
records= frappe.db.sql("""
SELECT
employee, leave_type, from_date, to_date, leaves, transaction_name,
transaction_type, is_carry_forward, is_expired
FROM `tabLeave Ledger Entry`
WHERE employee=%(employee)s AND leave_type=%(leave_type)s
AND docstatus=1
AND transaction_type = 'Leave Allocation'
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)
records = get_leave_ledger_entries(from_date, to_date, employee, leave_type)
for record in records:
# new allocation records with `is_expired=1` are created when leave expires
# these new records should not be considered, else it leads to negative leave balance
if record.is_expired:
continue
if record.to_date < getdate(to_date):
# leave allocations ending before to_date, reduce leaves taken within that period
# since they are already used, they won't expire
expired_leaves += record.leaves
expired_leaves += get_leaves_for_period(employee, leave_type,
record.from_date, record.to_date)
if record.from_date >= getdate(from_date):
new_allocation += record.leaves
if record.is_carry_forward:
carry_forwarded_leaves += record.leaves
else:
new_allocation += record.leaves
return new_allocation, expired_leaves
return new_allocation, expired_leaves, carry_forwarded_leaves
def get_chart_data(data):
def get_leave_ledger_entries(from_date: str, to_date: str, employee: str, leave_type: str) -> List[Dict]:
ledger = frappe.qb.DocType('Leave Ledger Entry')
records = (
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.is_carry_forward, ledger.is_expired
).where(
(ledger.docstatus == 1)
& (ledger.transaction_type == 'Leave Allocation')
& (ledger.employee == employee)
& (ledger.leave_type == leave_type)
& (
(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=True)
return records
def get_chart_data(data: List) -> Dict:
labels = []
datasets = []
employee_data = data
@ -224,7 +262,8 @@ def get_chart_data(data):
return chart
def get_dataset_for_chart(employee_data, datasets, labels):
def get_dataset_for_chart(employee_data: List, datasets: List, labels: List) -> List:
leaves = []
employee_data = sorted(employee_data, key=lambda k: k['employee_name'])

View File

@ -0,0 +1,161 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import unittest
import frappe
from frappe.utils import add_days, add_months, flt, get_year_ending, get_year_start, getdate
from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.hr.doctype.holiday_list.test_holiday_list import set_holiday_list
from erpnext.hr.doctype.leave_application.test_leave_application import (
get_first_sunday,
make_allocation_record,
)
from erpnext.hr.doctype.leave_ledger_entry.leave_ledger_entry import process_expired_allocation
from erpnext.hr.doctype.leave_type.test_leave_type import create_leave_type
from erpnext.hr.report.employee_leave_balance.employee_leave_balance import execute
from erpnext.payroll.doctype.salary_slip.test_salary_slip import (
make_holiday_list,
make_leave_application,
)
test_records = frappe.get_test_records('Leave Type')
class TestEmployeeLeaveBalance(unittest.TestCase):
def setUp(self):
for dt in ['Leave Application', 'Leave Allocation', 'Salary Slip', 'Leave Ledger Entry', 'Leave Type']:
frappe.db.delete(dt)
frappe.set_user('Administrator')
self.employee_id = make_employee('test_emp_leave_balance@example.com', company='_Test Company')
self.date = getdate()
self.year_start = getdate(get_year_start(self.date))
self.mid_year = add_months(self.year_start, 6)
self.year_end = getdate(get_year_ending(self.date))
self.holiday_list = make_holiday_list('_Test Emp Balance Holiday List', self.year_start, self.year_end)
def tearDown(self):
frappe.db.rollback()
@set_holiday_list('_Test Emp Balance Holiday List', '_Test Company')
def test_employee_leave_balance(self):
frappe.get_doc(test_records[0]).insert()
# 5 leaves
allocation1 = make_allocation_record(employee=self.employee_id, from_date=add_days(self.year_start, -11),
to_date=add_days(self.year_start, -1), leaves=5)
# 30 leaves
allocation2 = make_allocation_record(employee=self.employee_id, from_date=self.year_start, to_date=self.year_end)
# expires 5 leaves
process_expired_allocation()
# 4 days leave
first_sunday = get_first_sunday(self.holiday_list, for_date=self.year_start)
leave_application = make_leave_application(self.employee_id, add_days(first_sunday, 1), add_days(first_sunday, 4), '_Test Leave Type')
leave_application.reload()
filters = frappe._dict({
'from_date': allocation1.from_date,
'to_date': allocation2.to_date,
'employee': self.employee_id
})
report = execute(filters)
expected_data = [{
'leave_type': '_Test Leave Type',
'employee': self.employee_id,
'employee_name': 'test_emp_leave_balance@example.com',
'leaves_allocated': flt(allocation1.new_leaves_allocated + allocation2.new_leaves_allocated),
'leaves_expired': flt(allocation1.new_leaves_allocated),
'opening_balance': flt(0),
'leaves_taken': flt(leave_application.total_leave_days),
'closing_balance': flt(allocation2.new_leaves_allocated - leave_application.total_leave_days),
'indent': 1
}]
self.assertEqual(report[1], expected_data)
@set_holiday_list('_Test Emp Balance Holiday List', '_Test Company')
def test_opening_balance_on_alloc_boundary_dates(self):
frappe.get_doc(test_records[0]).insert()
# 30 leaves allocated
allocation1 = make_allocation_record(employee=self.employee_id, from_date=self.year_start, to_date=self.year_end)
# 4 days leave application in the first allocation
first_sunday = get_first_sunday(self.holiday_list, for_date=self.year_start)
leave_application = make_leave_application(self.employee_id, add_days(first_sunday, 1), add_days(first_sunday, 4), '_Test Leave Type')
leave_application.reload()
# Case 1: opening balance for first alloc boundary
filters = frappe._dict({
'from_date': self.year_start,
'to_date': self.year_end,
'employee': self.employee_id
})
report = execute(filters)
self.assertEqual(report[1][0].opening_balance, 0)
# Case 2: opening balance after leave application date
filters = frappe._dict({
'from_date': add_days(leave_application.to_date, 1),
'to_date': self.year_end,
'employee': self.employee_id
})
report = execute(filters)
self.assertEqual(report[1][0].opening_balance, (allocation1.new_leaves_allocated - leave_application.total_leave_days))
# Case 3: leave balance shows actual balance and not consumption balance as per remaining days near alloc end date
# eg: 3 days left for alloc to end, leave balance should still be 26 and not 3
filters = frappe._dict({
'from_date': add_days(self.year_end, -3),
'to_date': self.year_end,
'employee': self.employee_id
})
report = execute(filters)
self.assertEqual(report[1][0].opening_balance, (allocation1.new_leaves_allocated - leave_application.total_leave_days))
@set_holiday_list('_Test Emp Balance Holiday List', '_Test Company')
def test_opening_balance_considers_carry_forwarded_leaves(self):
leave_type = create_leave_type(
leave_type_name="_Test_CF_leave_expiry",
is_carry_forward=1)
leave_type.insert()
# 30 leaves allocated for first half of the year
allocation1 = make_allocation_record(employee=self.employee_id, from_date=self.year_start,
to_date=self.mid_year, leave_type=leave_type.name)
# 4 days leave application in the first allocation
first_sunday = get_first_sunday(self.holiday_list, for_date=self.year_start)
leave_application = make_leave_application(self.employee_id, first_sunday, add_days(first_sunday, 3), leave_type.name)
leave_application.reload()
# 30 leaves allocated for second half of the year + carry forward leaves (26) from the previous allocation
allocation2 = make_allocation_record(employee=self.employee_id, from_date=add_days(self.mid_year, 1), to_date=self.year_end,
carry_forward=True, leave_type=leave_type.name)
# Case 1: carry forwarded leaves considered in opening balance for second alloc
filters = frappe._dict({
'from_date': add_days(self.mid_year, 1),
'to_date': self.year_end,
'employee': self.employee_id
})
report = execute(filters)
# available leaves from old alloc
opening_balance = allocation1.new_leaves_allocated - leave_application.total_leave_days
self.assertEqual(report[1][0].opening_balance, opening_balance)
# Case 2: opening balance one day after alloc boundary = carry forwarded leaves + new leaves alloc
filters = frappe._dict({
'from_date': add_days(self.mid_year, 2),
'to_date': self.year_end,
'employee': self.employee_id
})
report = execute(filters)
# available leaves from old alloc
opening_balance = allocation2.new_leaves_allocated + (allocation1.new_leaves_allocated - leave_application.total_leave_days)
self.assertEqual(report[1][0].opening_balance, opening_balance)

View File

@ -0,0 +1,117 @@
# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors
# License: GNU General Public License v3. See license.txt
import unittest
import frappe
from frappe.utils import add_days, flt, get_year_ending, get_year_start, getdate
from erpnext.hr.doctype.employee.test_employee import make_employee
from erpnext.hr.doctype.holiday_list.test_holiday_list import set_holiday_list
from erpnext.hr.doctype.leave_application.test_leave_application import (
get_first_sunday,
make_allocation_record,
)
from erpnext.hr.doctype.leave_ledger_entry.leave_ledger_entry import process_expired_allocation
from erpnext.hr.report.employee_leave_balance_summary.employee_leave_balance_summary import execute
from erpnext.payroll.doctype.salary_slip.test_salary_slip import (
make_holiday_list,
make_leave_application,
)
test_records = frappe.get_test_records('Leave Type')
class TestEmployeeLeaveBalance(unittest.TestCase):
def setUp(self):
for dt in ['Leave Application', 'Leave Allocation', 'Salary Slip', 'Leave Ledger Entry', 'Leave Type']:
frappe.db.delete(dt)
frappe.set_user('Administrator')
self.employee_id = make_employee('test_emp_leave_balance@example.com', company='_Test Company')
self.employee_id = make_employee('test_emp_leave_balance@example.com', company='_Test Company')
self.date = getdate()
self.year_start = getdate(get_year_start(self.date))
self.year_end = getdate(get_year_ending(self.date))
self.holiday_list = make_holiday_list('_Test Emp Balance Holiday List', self.year_start, self.year_end)
def tearDown(self):
frappe.db.rollback()
@set_holiday_list('_Test Emp Balance Holiday List', '_Test Company')
def test_employee_leave_balance_summary(self):
frappe.get_doc(test_records[0]).insert()
# 5 leaves
allocation1 = make_allocation_record(employee=self.employee_id, from_date=add_days(self.year_start, -11),
to_date=add_days(self.year_start, -1), leaves=5)
# 30 leaves
allocation2 = make_allocation_record(employee=self.employee_id, from_date=self.year_start, to_date=self.year_end)
# 2 days leave within the first allocation
leave_application1 = make_leave_application(self.employee_id, add_days(self.year_start, -11), add_days(self.year_start, -10),
'_Test Leave Type')
leave_application1.reload()
# expires 3 leaves
process_expired_allocation()
# 4 days leave within the second allocation
first_sunday = get_first_sunday(self.holiday_list, for_date=self.year_start)
leave_application2 = make_leave_application(self.employee_id, add_days(first_sunday, 1), add_days(first_sunday, 4), '_Test Leave Type')
leave_application2.reload()
filters = frappe._dict({
'date': add_days(leave_application2.to_date, 1),
'company': '_Test Company',
'employee': self.employee_id
})
report = execute(filters)
expected_data = [[
self.employee_id,
'test_emp_leave_balance@example.com',
frappe.db.get_value('Employee', self.employee_id, 'department'),
flt(
allocation1.new_leaves_allocated # allocated = 5
+ allocation2.new_leaves_allocated # allocated = 30
- leave_application1.total_leave_days # leaves taken in the 1st alloc = 2
- (allocation1.new_leaves_allocated - leave_application1.total_leave_days) # leaves expired from 1st alloc = 3
- leave_application2.total_leave_days # leaves taken in the 2nd alloc = 4
)
]]
self.assertEqual(report[1], expected_data)
@set_holiday_list('_Test Emp Balance Holiday List', '_Test Company')
def test_get_leave_balance_near_alloc_expiry(self):
frappe.get_doc(test_records[0]).insert()
# 30 leaves allocated
allocation = make_allocation_record(employee=self.employee_id, from_date=self.year_start, to_date=self.year_end)
# 4 days leave application in the first allocation
first_sunday = get_first_sunday(self.holiday_list, for_date=self.year_start)
leave_application = make_leave_application(self.employee_id, add_days(first_sunday, 1), add_days(first_sunday, 4), '_Test Leave Type')
leave_application.reload()
# Leave balance should show actual balance, and not "consumption balance as per remaining days", near alloc end date
# eg: 3 days left for alloc to end, leave balance should still be 26 and not 3
filters = frappe._dict({
'date': add_days(self.year_end, -3),
'company': '_Test Company',
'employee': self.employee_id
})
report = execute(filters)
expected_data = [[
self.employee_id,
'test_emp_leave_balance@example.com',
frappe.db.get_value('Employee', self.employee_id, 'department'),
flt(allocation.new_leaves_allocated - leave_application.total_leave_days)
]]
self.assertEqual(report[1], expected_data)

View File

@ -1393,7 +1393,7 @@ class SalarySlip(TransactionBase):
'total_allocated_leaves': flt(leave_values.get('total_leaves')),
'expired_leaves': flt(leave_values.get('expired_leaves')),
'used_leaves': flt(leave_values.get('leaves_taken')),
'pending_leaves': flt(leave_values.get('pending_leaves')),
'pending_leaves': flt(leave_values.get('leaves_pending_approval')),
'available_leaves': flt(leave_values.get('remaining_leaves'))
})

View File

@ -1057,7 +1057,7 @@ def create_additional_salary(employee, payroll_period, amount):
}).submit()
return salary_date
def make_leave_application(employee, from_date, to_date, leave_type, company=None):
def make_leave_application(employee, from_date, to_date, leave_type, company=None, submit=True):
leave_application = frappe.get_doc(dict(
doctype = 'Leave Application',
employee = employee,
@ -1065,11 +1065,12 @@ def make_leave_application(employee, from_date, to_date, leave_type, company=Non
from_date = from_date,
to_date = to_date,
company = company or erpnext.get_default_company() or "_Test Company",
docstatus = 1,
status = "Approved",
leave_approver = 'test@example.com'
))
leave_application.submit()
)).insert()
if submit:
leave_application.submit()
return leave_application
@ -1087,15 +1088,16 @@ def setup_test():
frappe.db.set_value('HR Settings', None, 'leave_status_notification_template', None)
frappe.db.set_value('HR Settings', None, 'leave_approval_notification_template', None)
def make_holiday_list(holiday_list_name=None):
def make_holiday_list(list_name=None, from_date=None, to_date=None):
fiscal_year = get_fiscal_year(nowdate(), company=erpnext.get_default_company())
holiday_list = frappe.db.exists("Holiday List", holiday_list_name or "Salary Slip Test Holiday List")
name = list_name or "Salary Slip Test Holiday List"
holiday_list = frappe.db.exists("Holiday List", name)
if not holiday_list:
holiday_list = frappe.get_doc({
"doctype": "Holiday List",
"holiday_list_name": holiday_list_name or "Salary Slip Test Holiday List",
"from_date": fiscal_year[1],
"to_date": fiscal_year[2],
"holiday_list_name": name,
"from_date": from_date or fiscal_year[1],
"to_date": to_date or fiscal_year[2],
"weekly_off": "Sunday"
}).insert()
holiday_list.get_weekly_off_dates()

View File

@ -26,7 +26,7 @@
"fieldname": "total_allocated_leaves",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Total Allocated Leave",
"label": "Total Allocated Leave(s)",
"no_copy": 1,
"read_only": 1
},
@ -34,7 +34,7 @@
"fieldname": "expired_leaves",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Expired Leave",
"label": "Expired Leave(s)",
"no_copy": 1,
"read_only": 1
},
@ -42,7 +42,7 @@
"fieldname": "used_leaves",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Used Leave",
"label": "Used Leave(s)",
"no_copy": 1,
"read_only": 1
},
@ -50,7 +50,7 @@
"fieldname": "pending_leaves",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Pending Leave",
"label": "Leave(s) Pending Approval",
"no_copy": 1,
"read_only": 1
},
@ -58,7 +58,7 @@
"fieldname": "available_leaves",
"fieldtype": "Float",
"in_list_view": 1,
"label": "Available Leave",
"label": "Available Leave(s)",
"no_copy": 1,
"read_only": 1
}
@ -66,7 +66,7 @@
"index_web_pages_for_search": 1,
"istable": 1,
"links": [],
"modified": "2021-02-19 10:47:48.546724",
"modified": "2022-02-28 14:01:32.327204",
"modified_by": "Administrator",
"module": "Payroll",
"name": "Salary Slip Leave",
@ -74,5 +74,6 @@
"permissions": [],
"sort_field": "modified",
"sort_order": "DESC",
"states": [],
"track_changes": 1
}