refactor(HR): Auto Attendance
> moved all auto attendance settings from HR Settings to shift > added shift in attendance and Employee Attendance Log > reordered and cleaned up fields in HR Settings and Employee DocType
This commit is contained in:
parent
66e459b35d
commit
e0c5176383
@ -242,7 +242,7 @@ scheduler_events = {
|
||||
"erpnext.accounts.doctype.gl_entry.gl_entry.rename_gle_sle_docs",
|
||||
"erpnext.projects.doctype.project.project.hourly_reminder",
|
||||
"erpnext.projects.doctype.project.project.collect_project_status",
|
||||
"erpnext.hr.doctype.hr_settings.hr_settings.make_attendance_from_employee_attendance_log"
|
||||
"erpnext.hr.doctype.shift_type.shift_type.process_auto_attendance_for_all_shifts"
|
||||
],
|
||||
"daily": [
|
||||
"erpnext.stock.reorder_item.reorder_item",
|
||||
|
@ -17,6 +17,7 @@
|
||||
"attendance_date",
|
||||
"company",
|
||||
"department",
|
||||
"shift",
|
||||
"attendance_request",
|
||||
"amended_from"
|
||||
],
|
||||
@ -146,6 +147,12 @@
|
||||
"label": "Working Hours",
|
||||
"precision": "1",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "shift",
|
||||
"fieldtype": "Link",
|
||||
"label": "Shift",
|
||||
"options": "Shift Type"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-ok",
|
||||
|
@ -89,15 +89,16 @@ def add_attendance(events, start, end, conditions=None):
|
||||
if e not in events:
|
||||
events.append(e)
|
||||
|
||||
def mark_absent(employee, attendance_date):
|
||||
def mark_absent(employee, attendance_date, shift=None):
|
||||
employee_doc = frappe.get_doc('Employee', employee)
|
||||
if not frappe.db.exists('Attendance', {'employee':employee, 'attendance_date':attendance_date}):
|
||||
if not frappe.db.exists('Attendance', {'employee':employee, 'attendance_date':attendance_date, 'docstatus':('!=', '2')}):
|
||||
doc_dict = {
|
||||
'doctype': 'Attendance',
|
||||
'employee': employee,
|
||||
'attendance_date': attendance_date,
|
||||
'status': 'Absent',
|
||||
'company': employee_doc.company
|
||||
'company': employee_doc.company,
|
||||
'shift': shift
|
||||
}
|
||||
attendance = frappe.get_doc(doc_dict).insert()
|
||||
attendance.submit()
|
||||
|
@ -8,6 +8,7 @@ from frappe.model.document import Document
|
||||
import frappe.utils
|
||||
from frappe import _
|
||||
from erpnext.hr.doctype.daily_work_summary.daily_work_summary import get_user_emails_from_group
|
||||
from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday
|
||||
|
||||
class DailyWorkSummaryGroup(Document):
|
||||
def validate(self):
|
||||
@ -23,7 +24,7 @@ def trigger_emails():
|
||||
for d in groups:
|
||||
group_doc = frappe.get_doc("Daily Work Summary Group", d)
|
||||
if (is_current_hour(group_doc.send_emails_at)
|
||||
and not is_holiday_today(group_doc.holiday_list)
|
||||
and not is_holiday(group_doc.holiday_list)
|
||||
and group_doc.enabled):
|
||||
emails = get_user_emails_from_group(group_doc)
|
||||
# find emails relating to a company
|
||||
@ -38,15 +39,6 @@ def is_current_hour(hour):
|
||||
return frappe.utils.nowtime().split(':')[0] == hour.split(':')[0]
|
||||
|
||||
|
||||
def is_holiday_today(holiday_list):
|
||||
date = frappe.utils.today()
|
||||
if holiday_list:
|
||||
return frappe.get_all('Holiday List',
|
||||
dict(name=holiday_list, holiday_date=date)) and True or False
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
def send_summary():
|
||||
'''Send summary to everyone'''
|
||||
for d in frappe.get_all('Daily Work Summary', dict(status='Open')):
|
||||
|
@ -16,7 +16,6 @@
|
||||
"middle_name",
|
||||
"last_name",
|
||||
"employee_name",
|
||||
"attendance_device_id",
|
||||
"image",
|
||||
"column_break1",
|
||||
"company",
|
||||
@ -49,9 +48,12 @@
|
||||
"column_break_31",
|
||||
"grade",
|
||||
"branch",
|
||||
"organization_profile",
|
||||
"attendance_and_leave_details",
|
||||
"leave_policy",
|
||||
"attendance_device_id",
|
||||
"column_break_44",
|
||||
"holiday_list",
|
||||
"default_shift",
|
||||
"salary_information",
|
||||
"salary_mode",
|
||||
"bank_name",
|
||||
@ -399,12 +401,6 @@
|
||||
"oldfieldtype": "Link",
|
||||
"options": "Branch"
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"fieldname": "organization_profile",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Leave Details"
|
||||
},
|
||||
{
|
||||
"fieldname": "leave_policy",
|
||||
"fieldtype": "Link",
|
||||
@ -755,12 +751,28 @@
|
||||
"label": "Attendance Device ID (Biometric/RF tag ID)",
|
||||
"no_copy": 1,
|
||||
"unique": 1
|
||||
},
|
||||
{
|
||||
"collapsible": 1,
|
||||
"fieldname": "attendance_and_leave_details",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Attendance and Leave Details"
|
||||
},
|
||||
{
|
||||
"fieldname": "column_break_44",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"fieldname": "default_shift",
|
||||
"fieldtype": "Link",
|
||||
"label": "Default Shift",
|
||||
"options": "Shift Type"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-user",
|
||||
"idx": 24,
|
||||
"image_field": "image",
|
||||
"modified": "2019-05-29 17:33:11.988538",
|
||||
"modified": "2019-06-01 16:05:55.132180",
|
||||
"modified_by": "Administrator",
|
||||
"module": "HR",
|
||||
"name": "Employee",
|
||||
|
@ -8,13 +8,18 @@
|
||||
"employee",
|
||||
"employee_name",
|
||||
"log_type",
|
||||
"shift",
|
||||
"column_break_4",
|
||||
"time",
|
||||
"device_id",
|
||||
"skip_auto_attendance",
|
||||
"attendance",
|
||||
"entry_grace_period_consequence",
|
||||
"exit_grace_period_consequence"
|
||||
"exit_grace_period_consequence",
|
||||
"shift_start",
|
||||
"shift_end",
|
||||
"shift_actual_start",
|
||||
"shift_actual_end"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@ -50,12 +55,14 @@
|
||||
"label": "Location / Device ID"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "entry_grace_period_consequence",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
"label": "Entry Grace Period Consequence"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "exit_grace_period_consequence",
|
||||
"fieldtype": "Check",
|
||||
"hidden": 1,
|
||||
@ -68,10 +75,10 @@
|
||||
"options": "\nIN\nOUT"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "skip_auto_attendance",
|
||||
"fieldtype": "Check",
|
||||
"label": "Skip Auto Attendance",
|
||||
"read_only": 1
|
||||
"label": "Skip Auto Attendance"
|
||||
},
|
||||
{
|
||||
"fieldname": "attendance",
|
||||
@ -79,9 +86,40 @@
|
||||
"label": "Attendance Marked",
|
||||
"options": "Attendance",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "shift",
|
||||
"fieldtype": "Link",
|
||||
"label": "Shift",
|
||||
"options": "Shift Type",
|
||||
"read_only": 1
|
||||
},
|
||||
{
|
||||
"fieldname": "shift_start",
|
||||
"fieldtype": "Datetime",
|
||||
"hidden": 1,
|
||||
"label": "Shift Start"
|
||||
},
|
||||
{
|
||||
"fieldname": "shift_end",
|
||||
"fieldtype": "Datetime",
|
||||
"hidden": 1,
|
||||
"label": "Shift End"
|
||||
},
|
||||
{
|
||||
"fieldname": "shift_actual_start",
|
||||
"fieldtype": "Datetime",
|
||||
"hidden": 1,
|
||||
"label": "Shift Actual Start"
|
||||
},
|
||||
{
|
||||
"fieldname": "shift_actual_end",
|
||||
"fieldtype": "Datetime",
|
||||
"hidden": 1,
|
||||
"label": "Shift Actual End"
|
||||
}
|
||||
],
|
||||
"modified": "2019-05-24 13:40:01.287808",
|
||||
"modified": "2019-06-06 23:09:37.766717",
|
||||
"modified_by": "Administrator",
|
||||
"module": "HR",
|
||||
"name": "Employee Attendance Log",
|
||||
|
@ -4,24 +4,48 @@
|
||||
|
||||
from __future__ import unicode_literals
|
||||
import frappe
|
||||
from frappe.utils import now
|
||||
from frappe.utils import now, cint, get_datetime
|
||||
from frappe.model.document import Document
|
||||
from frappe import _
|
||||
|
||||
from erpnext.hr.doctype.shift_assignment.shift_assignment import get_actual_start_end_datetime_of_shift
|
||||
|
||||
class EmployeeAttendanceLog(Document):
|
||||
def validate(self):
|
||||
if frappe.db.exists('Employee Attendance Log', {'employee': self.employee, 'time': self.time}):
|
||||
frappe.throw(_('This log already exists for this employee.'))
|
||||
self.validate_duplicate_log()
|
||||
self.fetch_shift()
|
||||
|
||||
def validate_duplicate_log(self):
|
||||
doc = frappe.db.exists('Employee Attendance Log', {
|
||||
'employee': self.employee,
|
||||
'time': self.time,
|
||||
'name': ['!=', self.name]})
|
||||
if doc:
|
||||
doc_link = frappe.get_desk_link('Employee Attendance Log', doc)
|
||||
frappe.throw(_('This employee already has a log with the same timestamp.{0}')
|
||||
.format("<Br>" + doc_link))
|
||||
|
||||
def fetch_shift(self):
|
||||
shift_actual_timings = get_actual_start_end_datetime_of_shift(self.employee, get_datetime(self.time), True)
|
||||
if shift_actual_timings[0] and shift_actual_timings[1]:
|
||||
if not self.attendance:
|
||||
self.shift = shift_actual_timings[2].shift_type.name
|
||||
self.shift_actual_start = shift_actual_timings[0]
|
||||
self.shift_actual_end = shift_actual_timings[1]
|
||||
self.shift_start = shift_actual_timings[2].start_datetime
|
||||
self.shift_end = shift_actual_timings[2].end_datetime
|
||||
else:
|
||||
self.shift = None
|
||||
|
||||
@frappe.whitelist()
|
||||
def add_log_based_on_employee_field(employee_field_value, timestamp, device_id=None, log_type=None, employee_fieldname='attendance_device_id'):
|
||||
def add_log_based_on_employee_field(employee_field_value, timestamp, device_id=None, log_type=None, skip_auto_attendance=0, employee_fieldname='attendance_device_id'):
|
||||
"""Finds the relevant Employee using the employee field value and creates a Employee Attendance Log.
|
||||
|
||||
:param employee_field_value: The value to look for in employee field.
|
||||
:param timestamp: The timestamp of the Log. Currently expected in the following format as string: '2019-05-08 10:48:08.000000'
|
||||
:param device_id: (optional)Location / Device ID. A short string is expected.
|
||||
:param log_type: (optional)Direction of the Punch if available (IN/OUT).
|
||||
:param skip_auto_attendance: (optional)Skip auto attendance field will be set for this log(0/1).
|
||||
:param employee_fieldname: (Default: attendance_device_id)Name of the field in Employee DocType based on which employee lookup will happen.
|
||||
"""
|
||||
|
||||
@ -40,12 +64,13 @@ def add_log_based_on_employee_field(employee_field_value, timestamp, device_id=N
|
||||
doc.time = timestamp
|
||||
doc.device_id = device_id
|
||||
doc.log_type = log_type
|
||||
if cint(skip_auto_attendance) == 1: doc.skip_auto_attendance = '1'
|
||||
doc.insert()
|
||||
|
||||
|
||||
return doc
|
||||
|
||||
|
||||
def mark_attendance_and_link_log(logs, attendance_status, attendance_date, working_hours=None, company=None):
|
||||
def mark_attendance_and_link_log(logs, attendance_status, attendance_date, working_hours=None, shift=None):
|
||||
"""Creates an attendance and links the attendance to the Employee Attendance Log.
|
||||
Note: If attendance is already present for the given date, the logs are marked as skipped and no exception is thrown.
|
||||
|
||||
@ -63,14 +88,15 @@ def mark_attendance_and_link_log(logs, attendance_status, attendance_date, worki
|
||||
return None
|
||||
elif attendance_status in ('Present', 'Absent', 'Half Day'):
|
||||
employee_doc = frappe.get_doc('Employee', employee)
|
||||
if not frappe.db.exists('Attendance', {'employee':employee, 'attendance_date':attendance_date}):
|
||||
if not frappe.db.exists('Attendance', {'employee':employee, 'attendance_date':attendance_date, 'docstatus':('!=', '2')}):
|
||||
doc_dict = {
|
||||
'doctype': 'Attendance',
|
||||
'employee': employee,
|
||||
'attendance_date': attendance_date,
|
||||
'status': attendance_status,
|
||||
'working_hours': working_hours,
|
||||
'company': employee_doc.company
|
||||
'company': employee_doc.company,
|
||||
'shift': shift
|
||||
}
|
||||
attendance = frappe.get_doc(doc_dict).insert()
|
||||
attendance.submit()
|
||||
@ -85,3 +111,48 @@ def mark_attendance_and_link_log(logs, attendance_status, attendance_date, worki
|
||||
return None
|
||||
else:
|
||||
frappe.throw(_('{} is an invalid Attendance Status.').format(attendance_status))
|
||||
|
||||
|
||||
def calculate_working_hours(logs, check_in_out_type, working_hours_calc_type):
|
||||
"""Given a set of logs in chronological order calculates the total working hours based on the parameters.
|
||||
Zero is returned for all invalid cases.
|
||||
|
||||
:param logs: The List of 'Employee Attendance Log'.
|
||||
:param check_in_out_type: One of: 'Alternating entries as IN and OUT during the same shift', 'Strictly based on Log Type in Employee Attendance Log'
|
||||
:param working_hours_calc_type: One of: 'First Check-in and Last Check-out', 'Every Valid Check-in and Check-out'
|
||||
"""
|
||||
total_hours = 0
|
||||
if check_in_out_type == 'Alternating entries as IN and OUT during the same shift':
|
||||
if working_hours_calc_type == 'First Check-in and Last Check-out':
|
||||
# assumption in this case: First log always taken as IN, Last log always taken as OUT
|
||||
total_hours = time_diff_in_hours(logs[0].time, logs[-1].time)
|
||||
elif working_hours_calc_type == 'Every Valid Check-in and Check-out':
|
||||
while len(logs) >= 2:
|
||||
total_hours += time_diff_in_hours(logs[0].time, logs[1].time)
|
||||
del logs[:2]
|
||||
|
||||
elif check_in_out_type == 'Strictly based on Log Type in Employee Attendance Log':
|
||||
if working_hours_calc_type == 'First Check-in and Last Check-out':
|
||||
first_in_log = logs[find_index_in_dict(logs, 'log_type', 'IN')]
|
||||
last_out_log = logs[len(logs)-1-find_index_in_dict(reversed(logs), 'log_type', 'OUT')]
|
||||
if first_in_log and last_out_log:
|
||||
total_hours = time_diff_in_hours(first_in_log.time, last_out_log.time)
|
||||
elif working_hours_calc_type == 'Every Valid Check-in and Check-out':
|
||||
in_log = out_log = None
|
||||
for log in logs:
|
||||
if in_log and out_log:
|
||||
total_hours += time_diff_in_hours(in_log.time, out_log.time)
|
||||
in_log = out_log = None
|
||||
if not in_log:
|
||||
in_log = log if log.log_type == 'IN' else None
|
||||
elif not out_log:
|
||||
out_log = log if log.log_type == 'OUT' else None
|
||||
if in_log and out_log:
|
||||
total_hours += time_diff_in_hours(in_log.time, out_log.time)
|
||||
return total_hours
|
||||
|
||||
def time_diff_in_hours(start, end):
|
||||
return round((end-start).total_seconds() / 3600, 1)
|
||||
|
||||
def find_index_in_dict(dict_list, key, value):
|
||||
return next((index for (index, d) in enumerate(dict_list) if d[key] == value), None)
|
||||
|
@ -8,7 +8,7 @@ from frappe.utils import now_datetime, nowdate, to_timedelta
|
||||
import unittest
|
||||
from datetime import timedelta
|
||||
|
||||
from erpnext.hr.doctype.employee_attendance_log.employee_attendance_log import add_log_based_on_employee_field, mark_attendance_and_link_log
|
||||
from erpnext.hr.doctype.employee_attendance_log.employee_attendance_log import add_log_based_on_employee_field, mark_attendance_and_link_log, calculate_working_hours
|
||||
from erpnext.hr.doctype.employee.test_employee import make_employee
|
||||
|
||||
class TestEmployeeAttendanceLog(unittest.TestCase):
|
||||
@ -44,6 +44,42 @@ class TestEmployeeAttendanceLog(unittest.TestCase):
|
||||
'employee':employee, 'attendance_date':now_date})
|
||||
self.assertEqual(attendance_count, 1)
|
||||
|
||||
def test_calculate_working_hours(self):
|
||||
check_in_out_type = ['Alternating entries as IN and OUT during the same shift',
|
||||
'Strictly based on Log Type in Employee Attendance Log']
|
||||
working_hours_calc_type = ['First Check-in and Last Check-out',
|
||||
'Every Valid Check-in and Check-out']
|
||||
logs_type_1 = [
|
||||
{'time':now_datetime()-timedelta(minutes=390)},
|
||||
{'time':now_datetime()-timedelta(minutes=300)},
|
||||
{'time':now_datetime()-timedelta(minutes=270)},
|
||||
{'time':now_datetime()-timedelta(minutes=90)},
|
||||
{'time':now_datetime()-timedelta(minutes=0)}
|
||||
]
|
||||
logs_type_2 = [
|
||||
{'time':now_datetime()-timedelta(minutes=390),'log_type':'OUT'},
|
||||
{'time':now_datetime()-timedelta(minutes=360),'log_type':'IN'},
|
||||
{'time':now_datetime()-timedelta(minutes=300),'log_type':'OUT'},
|
||||
{'time':now_datetime()-timedelta(minutes=290),'log_type':'IN'},
|
||||
{'time':now_datetime()-timedelta(minutes=260),'log_type':'OUT'},
|
||||
{'time':now_datetime()-timedelta(minutes=240),'log_type':'IN'},
|
||||
{'time':now_datetime()-timedelta(minutes=150),'log_type':'IN'},
|
||||
{'time':now_datetime()-timedelta(minutes=60),'log_type':'OUT'}
|
||||
]
|
||||
logs_type_1 = [frappe._dict(x) for x in logs_type_1]
|
||||
logs_type_2 = [frappe._dict(x) for x in logs_type_2]
|
||||
|
||||
working_hours = calculate_working_hours(logs_type_1,check_in_out_type[0],working_hours_calc_type[0])
|
||||
self.assertEqual(working_hours, 6.5)
|
||||
|
||||
working_hours = calculate_working_hours(logs_type_1,check_in_out_type[0],working_hours_calc_type[1])
|
||||
self.assertEqual(working_hours, 4.5)
|
||||
|
||||
working_hours = calculate_working_hours(logs_type_2,check_in_out_type[1],working_hours_calc_type[0])
|
||||
self.assertEqual(working_hours, 5)
|
||||
|
||||
working_hours = calculate_working_hours(logs_type_2,check_in_out_type[1],working_hours_calc_type[1])
|
||||
self.assertEqual(working_hours, 4.5)
|
||||
|
||||
def make_n_attendance_logs(employee, n, hours_to_reverse=1):
|
||||
logs = [make_attendance_log(employee, now_datetime() - timedelta(hours=hours_to_reverse, minutes=n+1))]
|
||||
|
@ -4,7 +4,7 @@
|
||||
from __future__ import unicode_literals
|
||||
import frappe
|
||||
import json
|
||||
from frappe.utils import cint, getdate, formatdate
|
||||
from frappe.utils import cint, getdate, formatdate, today
|
||||
from frappe import throw, _
|
||||
from frappe.model.document import Document
|
||||
|
||||
@ -84,3 +84,13 @@ def get_events(start, end, filters=None):
|
||||
fields=['name', '`tabHoliday`.holiday_date', '`tabHoliday`.description', '`tabHoliday List`.color'],
|
||||
filters = filters,
|
||||
update={"allDay": 1})
|
||||
|
||||
|
||||
def is_holiday(holiday_list, date=today()):
|
||||
"""Returns true if the given date is a holiday in the given holiday list
|
||||
"""
|
||||
if holiday_list:
|
||||
return bool(frappe.get_all('Holiday List',
|
||||
dict(name=holiday_list, holiday_date=date)))
|
||||
else:
|
||||
return False
|
||||
|
@ -12,7 +12,7 @@ class TestHolidayList(unittest.TestCase):
|
||||
def test_holiday_list(self):
|
||||
today_date = getdate()
|
||||
test_holiday_dates = [today_date-timedelta(days=5), today_date-timedelta(days=4)]
|
||||
holiday_list = make_holiday_list("test_is_holiday",
|
||||
holiday_list = make_holiday_list("test_holiday_list",
|
||||
holiday_dates=[
|
||||
{'holiday_date': test_holiday_dates[0], 'description': 'test holiday'},
|
||||
{'holiday_date': test_holiday_dates[1], 'description': 'test holiday2'}
|
||||
|
@ -7,28 +7,22 @@
|
||||
"employee_settings",
|
||||
"retirement_age",
|
||||
"emp_created_by",
|
||||
"leave_approval_notification_template",
|
||||
"leave_status_notification_template",
|
||||
"default_shift",
|
||||
"column_break_4",
|
||||
"stop_birthday_reminders",
|
||||
"maintain_bill_work_hours_same",
|
||||
"leave_approver_mandatory_in_leave_application",
|
||||
"expense_approver_mandatory_in_expense_claim",
|
||||
"payroll_settings",
|
||||
"include_holidays_in_total_working_days",
|
||||
"max_working_hours_against_timesheet",
|
||||
"column_break_11",
|
||||
"email_salary_slip_to_employee",
|
||||
"encrypt_salary_slips_in_emails",
|
||||
"password_policy",
|
||||
"max_working_hours_against_timesheet",
|
||||
"leave_settings",
|
||||
"show_leaves_of_all_department_members_in_calendar",
|
||||
"auto_attendance_section",
|
||||
"disable_auto_attendance",
|
||||
"attendance_for_employee_without_shift",
|
||||
"column_break_23",
|
||||
"process_attendance_after",
|
||||
"last_sync_of_attendance_log"
|
||||
"leave_approval_notification_template",
|
||||
"leave_status_notification_template",
|
||||
"column_break_18",
|
||||
"leave_approver_mandatory_in_leave_application",
|
||||
"show_leaves_of_all_department_members_in_calendar"
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
@ -67,16 +61,12 @@
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "Don't send Employee Birthday Reminders",
|
||||
"fieldname": "stop_birthday_reminders",
|
||||
"fieldtype": "Check",
|
||||
"label": "Stop Birthday Reminders"
|
||||
},
|
||||
{
|
||||
"fieldname": "maintain_bill_work_hours_same",
|
||||
"fieldtype": "Check",
|
||||
"label": "Maintain Billing Hours and Working Hours Same on Timesheet"
|
||||
},
|
||||
{
|
||||
"default": "1",
|
||||
"fieldname": "leave_approver_mandatory_in_leave_application",
|
||||
@ -95,6 +85,7 @@
|
||||
"label": "Payroll Settings"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"description": "If checked, Total no. of Working Days will include holidays, and this will reduce the value of Salary Per Day",
|
||||
"fieldname": "include_holidays_in_total_working_days",
|
||||
"fieldtype": "Check",
|
||||
@ -108,6 +99,7 @@
|
||||
"label": "Email Salary Slip to Employee"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"depends_on": "eval: doc.email_salary_slip_to_employee == 1;",
|
||||
"description": "The salary slip emailed to the employee will be password protected, the password will be generated based on the password policy.",
|
||||
"fieldname": "encrypt_salary_slips_in_emails",
|
||||
@ -133,58 +125,24 @@
|
||||
"label": "Leave Settings"
|
||||
},
|
||||
{
|
||||
"default": "0",
|
||||
"fieldname": "show_leaves_of_all_department_members_in_calendar",
|
||||
"fieldtype": "Check",
|
||||
"label": "Show Leaves Of All Department Members In Calendar"
|
||||
},
|
||||
{
|
||||
"description": "Attendance automatically marked based Employee Attendance Log",
|
||||
"fieldname": "auto_attendance_section",
|
||||
"fieldtype": "Section Break",
|
||||
"label": "Auto Attendance"
|
||||
},
|
||||
{
|
||||
"fieldname": "default_shift",
|
||||
"fieldtype": "Link",
|
||||
"label": "Default Shift",
|
||||
"options": "Shift Type"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.disable_auto_attendance",
|
||||
"fieldname": "attendance_for_employee_without_shift",
|
||||
"fieldtype": "Select",
|
||||
"label": "Attendance for Employee Without Shift",
|
||||
"options": "Skip\nBased on Default Shift\nAt least one Employee Attendance Log per day as present"
|
||||
},
|
||||
{
|
||||
"fieldname": "disable_auto_attendance",
|
||||
"fieldtype": "Check",
|
||||
"label": "Disable Auto Attendance"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.disable_auto_attendance",
|
||||
"fieldname": "column_break_23",
|
||||
"fieldname": "column_break_11",
|
||||
"fieldtype": "Column Break"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.disable_auto_attendance",
|
||||
"description": "Attendance will be marked automatically only after this date.",
|
||||
"fieldname": "process_attendance_after",
|
||||
"fieldtype": "Date",
|
||||
"label": "Process Attendance After"
|
||||
},
|
||||
{
|
||||
"depends_on": "eval:!doc.disable_auto_attendance",
|
||||
"description": "Last Known Successful Sync of Employee Attendance Log. Reset this only if you are sure that all Logs are synced from all the locations. Please don't modify this if you are unsure.",
|
||||
"fieldname": "last_sync_of_attendance_log",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "Last Sync of Attendance Log"
|
||||
"fieldname": "column_break_18",
|
||||
"fieldtype": "Column Break"
|
||||
}
|
||||
],
|
||||
"icon": "fa fa-cog",
|
||||
"idx": 1,
|
||||
"issingle": 1,
|
||||
"modified": "2019-05-22 12:50:40.189766",
|
||||
"modified": "2019-05-31 16:18:50.245872",
|
||||
"modified_by": "Administrator",
|
||||
"module": "HR",
|
||||
"name": "HR Settings",
|
||||
|
@ -5,15 +5,8 @@
|
||||
|
||||
from __future__ import unicode_literals
|
||||
import frappe
|
||||
from frappe.utils import get_datetime, getdate
|
||||
from datetime import timedelta
|
||||
from frappe import _
|
||||
|
||||
from frappe.model.document import Document
|
||||
from erpnext.hr.doctype.shift_assignment.shift_assignment import get_employee_shift_timings, get_employee_shift
|
||||
from erpnext.hr.doctype.employee_attendance_log.employee_attendance_log import mark_attendance_and_link_log
|
||||
from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee
|
||||
from erpnext.hr.doctype.attendance.attendance import mark_absent
|
||||
|
||||
class HRSettings(Document):
|
||||
def validate(self):
|
||||
@ -29,238 +22,3 @@ class HRSettings(Document):
|
||||
if self.email_salary_slip_to_employee and self.encrypt_salary_slips_in_emails:
|
||||
if not self.password_policy:
|
||||
frappe.throw(_("Password policy for Salary Slips is not set"))
|
||||
|
||||
|
||||
def make_attendance_from_employee_attendance_log():
|
||||
hr_settings = frappe.db.get_singles_dict("HR Settings")
|
||||
if hr_settings.disable_auto_attendance == '1' or not hr_settings.process_attendance_after:
|
||||
return
|
||||
|
||||
frappe.flags.hr_settings_for_auto_attendance = hr_settings
|
||||
filters = {'skip_auto_attendance':'0', 'attendance_marked':('is', 'not set'), 'time':('>=', hr_settings.process_attendance_after)}
|
||||
|
||||
logs = frappe.db.get_all('Employee Attendance Log', fields="*", filters=filters, order_by="employee,time")
|
||||
single_employee_logs = []
|
||||
for log in logs:
|
||||
if not len(single_employee_logs) or (len(single_employee_logs) and single_employee_logs[0].employee == log.employee):
|
||||
single_employee_logs.append(log)
|
||||
else:
|
||||
process_single_employee_logs(single_employee_logs, hr_settings)
|
||||
single_employee_logs = [log]
|
||||
process_single_employee_logs(single_employee_logs, hr_settings)
|
||||
|
||||
def process_single_employee_logs(logs, hr_settings=None):
|
||||
"""Takes logs of a single employee in chronological order and tries to mark attendance for that employee.
|
||||
"""
|
||||
last_log = logs[-1]
|
||||
if not hr_settings:
|
||||
hr_settings = frappe.db.get_singles_dict("HR Settings")
|
||||
consider_default_shift = bool(hr_settings.attendance_for_employee_without_shift == 'Based on Default Shift')
|
||||
employee_last_sync = get_employee_attendance_log_last_sync(last_log.employee, hr_settings, last_log)
|
||||
while logs:
|
||||
actual_shift_start, actual_shift_end, shift_details = get_actual_start_end_datetime_of_shift(logs[0].employee, logs[0].time, consider_default_shift)
|
||||
if actual_shift_end and actual_shift_end >= employee_last_sync:
|
||||
break # skip processing employee if last_sync timestamp is in the middle of a shift
|
||||
if not actual_shift_start and not actual_shift_end: # when the log does not belong to any 'actual' shift timings
|
||||
if not shift_details: # employee does not have any future shifts assigned
|
||||
if hr_settings.attendance_for_employee_without_shift == 'At least one Employee Attendance Log per day as present':
|
||||
single_day_logs = [logs.pop(0)]
|
||||
while logs and logs[0].time.date() == single_day_logs[0].time.date():
|
||||
single_day_logs.append(logs.pop(0))
|
||||
mark_attendance_and_link_log(single_day_logs, 'Present', single_day_logs[0].time.date())
|
||||
continue
|
||||
else:
|
||||
mark_attendance_and_link_log(logs, 'Skip', None) # skipping attendance for all logs
|
||||
break
|
||||
else:
|
||||
mark_attendance_and_link_log([logs.pop(0)], 'Skip', None) # skipping single log
|
||||
continue
|
||||
single_shift_logs = [logs.pop(0)]
|
||||
while logs and logs[0].time <= actual_shift_end:
|
||||
single_shift_logs.append(logs.pop(0))
|
||||
process_single_employee_shift_logs(single_shift_logs, shift_details)
|
||||
mark_absent_for_dates_with_no_attendance(last_log.employee, employee_last_sync, hr_settings)
|
||||
|
||||
def mark_absent_for_dates_with_no_attendance(employee, employee_last_sync, hr_settings=None):
|
||||
"""Marks Absents for the given employee on working days which have no attendance marked.
|
||||
The Absent is marked starting from one shift before the employee_last_sync
|
||||
going back to 'hr_settings.process_attendance_after' or employee creation date.
|
||||
"""
|
||||
if not hr_settings:
|
||||
hr_settings = frappe.db.get_singles_dict("HR Settings")
|
||||
consider_default_shift = bool(hr_settings.attendance_for_employee_without_shift == 'Based on Default Shift')
|
||||
employee_date_of_joining = frappe.db.get_value('Employee', employee, 'date_of_joining')
|
||||
if not employee_date_of_joining:
|
||||
employee_date_of_joining = frappe.db.get_value('Employee', employee, 'creation').date()
|
||||
start_date = max(getdate(hr_settings.process_attendance_after), employee_date_of_joining)
|
||||
|
||||
actual_shift_datetime = get_actual_start_end_datetime_of_shift(employee, employee_last_sync, consider_default_shift)
|
||||
last_shift_time = actual_shift_datetime[0] if actual_shift_datetime[0] else employee_last_sync
|
||||
prev_shift = get_employee_shift(employee, last_shift_time.date()-timedelta(days=1), consider_default_shift, 'reverse')
|
||||
if prev_shift:
|
||||
end_date = prev_shift.start_datetime.date()
|
||||
elif hr_settings.attendance_for_employee_without_shift == 'At least one Employee Attendance Log per day as present':
|
||||
for date in get_filtered_date_list(employee, "All Dates", start_date, employee_last_sync.date(), True, get_holiday_list_for_employee(employee, False)):
|
||||
mark_absent(employee, date)
|
||||
return
|
||||
else:
|
||||
return
|
||||
|
||||
if consider_default_shift:
|
||||
for date in get_filtered_date_list(employee, "All Dates", start_date, end_date):
|
||||
if get_employee_shift(employee, date, consider_default_shift):
|
||||
mark_absent(employee, date)
|
||||
elif hr_settings.attendance_for_employee_without_shift == 'At least one Employee Attendance Log per day as present':
|
||||
for date in get_filtered_date_list(employee, "All Dates", start_date, employee_last_sync.date(), True, get_holiday_list_for_employee(employee, False)):
|
||||
mark_absent(employee, date)
|
||||
else:
|
||||
for date in get_filtered_date_list(employee, "Assigned Shifts", start_date, end_date):
|
||||
if get_employee_shift(employee, date, consider_default_shift):
|
||||
mark_absent(employee, date)
|
||||
|
||||
|
||||
def get_filtered_date_list(employee, base_dates_set, start_date, end_date, filter_attendance=True, holiday_list=None):
|
||||
"""
|
||||
:param base_dates_set: One of: "All Dates", "Assigned Shifts"
|
||||
"""
|
||||
if base_dates_set == "All Dates":
|
||||
base_dates_query = """select adddate(%(start_date)s, t2.i*100 + t1.i*10 + t0.i) selected_date from
|
||||
(select 0 i union select 1 union select 2 union select 3 union select 4 union select 5 union select 6 union select 7 union select 8 union select 9) t0,
|
||||
(select 0 i union select 1 union select 2 union select 3 union select 4 union select 5 union select 6 union select 7 union select 8 union select 9) t1,
|
||||
(select 0 i union select 1 union select 2 union select 3 union select 4 union select 5 union select 6 union select 7 union select 8 union select 9) t2"""
|
||||
else:
|
||||
base_dates_query = "select date as selected_date from `tabShift Assignment` where docstatus = '1' and employee = %(employee)s and date >= %(start_date)s"
|
||||
|
||||
condition_query = ''
|
||||
if filter_attendance:
|
||||
condition_query += """and a.selected_date not in (
|
||||
select attendance_date from `tabAttendance`
|
||||
where docstatus = '1' and employee = %(employee)s
|
||||
and attendance_date between %(start_date)s and %(end_date)s)"""
|
||||
if holiday_list:
|
||||
condition_query += """and a.selected_date not in (
|
||||
select holiday_date from `tabHoliday` where parenttype = 'Holiday List' and
|
||||
parentfield = 'holidays' and parent = %(holiday_list)s
|
||||
and holiday_date between %(start_date)s and %(end_date)s)"""
|
||||
|
||||
dates = frappe.db.sql("""select * from
|
||||
({base_dates_query}) as a
|
||||
where a.selected_date <= %(end_date)s {condition_query}
|
||||
""".format(base_dates_query=base_dates_query,condition_query=condition_query),
|
||||
{"employee":employee, "start_date":start_date, "end_date":end_date, "holiday_list":holiday_list},as_list=True)
|
||||
|
||||
return [getdate(date[0]) for date in dates]
|
||||
|
||||
|
||||
def process_single_employee_shift_logs(logs, shift_details):
|
||||
"""Mark Attendance for a set of logs belonging to a single shift.
|
||||
Assumtion:
|
||||
1. These logs belongs to an single shift, single employee and is not in a holiday date.
|
||||
2. Logs are in chronological order
|
||||
"""
|
||||
if shift_details.shift_type.enable_auto_attendance:
|
||||
mark_attendance_and_link_log(logs, 'Skip', None)
|
||||
return
|
||||
check_in_out_type = shift_details.shift_type.determine_check_in_and_check_out
|
||||
working_hours_calc_type = shift_details.shift_type.working_hours_calculation_based_on
|
||||
total_working_hours = calculate_working_hours(logs, check_in_out_type, working_hours_calc_type)
|
||||
if shift_details.working_hours_threshold_for_absent and total_working_hours < shift_details.working_hours_threshold_for_absent:
|
||||
mark_attendance_and_link_log(logs, 'Absent', shift_details.start_datetime.date(), total_working_hours)
|
||||
return
|
||||
if shift_details.working_hours_threshold_for_half_day and total_working_hours < shift_details.working_hours_threshold_for_half_day:
|
||||
mark_attendance_and_link_log(logs, 'Half Day', shift_details.start_datetime.date(), total_working_hours)
|
||||
return
|
||||
mark_attendance_and_link_log(logs, 'Present', shift_details.start_datetime.date(), total_working_hours)
|
||||
|
||||
|
||||
def calculate_working_hours(logs, check_in_out_type, working_hours_calc_type):
|
||||
"""Given a set of logs in chronological order calculates the total working hours based on the parameters.
|
||||
Zero is returned for all invalid cases.
|
||||
|
||||
:param logs: The List of 'Employee Attendance Log'.
|
||||
:param check_in_out_type: One of: 'Alternating entries as IN and OUT during the same shift', 'Strictly based on Log Type in Employee Attendance Log'
|
||||
:param working_hours_calc_type: One of: 'First Check-in and Last Check-out', 'Every Valid Check-in and Check-out'
|
||||
"""
|
||||
total_hours = 0
|
||||
if check_in_out_type == 'Alternating entries as IN and OUT during the same shift':
|
||||
if working_hours_calc_type == 'First Check-in and Last Check-out':
|
||||
# assumption in this case: First log always IN, Last log always OUT
|
||||
total_hours = time_diff_in_hours(logs[0].time, logs[-1].time)
|
||||
elif working_hours_calc_type == 'Every Valid Check-in and Check-out':
|
||||
while len(logs) >= 2:
|
||||
total_hours += time_diff_in_hours(logs[0].time, logs[1].time)
|
||||
del logs[:2]
|
||||
|
||||
elif check_in_out_type == 'Strictly based on Log Type in Employee Attendance Log':
|
||||
if working_hours_calc_type == 'First Check-in and Last Check-out':
|
||||
first_in_log = logs[find_index_in_dict(logs, 'log_type', 'IN')]
|
||||
last_out_log = logs[len(logs)-1-find_index_in_dict(reversed(logs), 'log_type', 'OUT')]
|
||||
if first_in_log and last_out_log:
|
||||
total_hours = time_diff_in_hours(first_in_log.time, last_out_log.time)
|
||||
elif working_hours_calc_type == 'Every Valid Check-in and Check-out':
|
||||
in_log = out_log = None
|
||||
for log in logs:
|
||||
if in_log and out_log:
|
||||
total_hours += time_diff_in_hours(in_log.time, out_log.time)
|
||||
in_log = out_log = None
|
||||
if not in_log:
|
||||
in_log = log if log.log_type == 'IN' else None
|
||||
elif not out_log:
|
||||
out_log = log if log.log_type == 'OUT' else None
|
||||
if in_log and out_log:
|
||||
total_hours += time_diff_in_hours(in_log.time, out_log.time)
|
||||
return total_hours
|
||||
|
||||
|
||||
def time_diff_in_hours(start, end):
|
||||
return round((end-start).total_seconds() / 3600, 1)
|
||||
|
||||
def find_index_in_dict(dict_list, key, value):
|
||||
return next((index for (index, d) in enumerate(dict_list) if d[key] == value), None)
|
||||
|
||||
def get_actual_start_end_datetime_of_shift(employee, for_datetime, consider_default_shift=False):
|
||||
"""Takes a datetime and returns the 'actual' start datetime and end datetime of the shift in which the timestamp belongs.
|
||||
Here 'actual' means - taking in to account the "begin_check_in_before_shift_start_time" and "allow_check_out_after_shift_end_time".
|
||||
None is returned if the timestamp is outside any actual shift timings.
|
||||
Shift Details is also returned(current/upcoming i.e. if timestamp not in any actual shift then details of next shift returned)
|
||||
"""
|
||||
actual_shift_start = actual_shift_end = shift_details = None
|
||||
shift_timings_as_per_timestamp = get_employee_shift_timings(employee, for_datetime, consider_default_shift)
|
||||
prev_shift, curr_shift, next_shift = shift_timings_as_per_timestamp
|
||||
timestamp_list = []
|
||||
for shift in shift_timings_as_per_timestamp:
|
||||
if shift:
|
||||
timestamp_list.extend([shift.actual_start, shift.actual_end])
|
||||
else:
|
||||
timestamp_list.extend([None, None])
|
||||
timestamp_index = None
|
||||
for index, timestamp in enumerate(timestamp_list):
|
||||
if timestamp and for_datetime <= timestamp:
|
||||
timestamp_index = index
|
||||
break
|
||||
if timestamp_index and timestamp_index%2 == 1:
|
||||
shift_details = shift_timings_as_per_timestamp[int((timestamp_index-1)/2)]
|
||||
actual_shift_start = shift_details.actual_start
|
||||
actual_shift_end = shift_details.actual_end
|
||||
elif timestamp_index:
|
||||
shift_details = shift_timings_as_per_timestamp[int(timestamp_index/2)]
|
||||
|
||||
return actual_shift_start, actual_shift_end, shift_details
|
||||
|
||||
|
||||
def get_employee_attendance_log_last_sync(employee, hr_settings=None, last_log=None):
|
||||
"""This functions returns a last sync timestamp for the given employee.
|
||||
"""
|
||||
# when using inside auto attendance function 'last_log', 'hr_setting' is passed along
|
||||
if last_log:
|
||||
last_log_time = [last_log]
|
||||
else:
|
||||
last_log_time = frappe.db.get_all('Employee Attendance Log', fields="time", filters={'employee':employee}, limit=1, order_by='time desc')
|
||||
if not hr_settings:
|
||||
hr_settings = frappe.db.get_singles_dict("HR Settings")
|
||||
|
||||
if last_log_time and hr_settings.last_sync_of_attendance_log:
|
||||
return max(last_log_time[0].time, get_datetime(hr_settings.last_sync_of_attendance_log))
|
||||
elif last_log_time:
|
||||
return last_log_time[0].time
|
||||
return get_datetime(hr_settings.last_sync_of_attendance_log) if hr_settings.last_sync_of_attendance_log else None
|
||||
|
@ -10,68 +10,4 @@ from frappe.utils import now_datetime
|
||||
from datetime import timedelta
|
||||
|
||||
class TestHRSettings(unittest.TestCase):
|
||||
def test_get_employee_attendance_log_last_sync(self):
|
||||
doc = frappe.get_doc("HR Settings")
|
||||
doc.last_sync_of_attendance_log = None
|
||||
doc.save()
|
||||
hr_settings = frappe.db.get_singles_dict("HR Settings")
|
||||
doc.last_sync_of_attendance_log = now_datetime()
|
||||
doc.save()
|
||||
|
||||
from erpnext.hr.doctype.hr_settings.hr_settings import get_employee_attendance_log_last_sync
|
||||
employee = make_employee("test_attendance_log_last_sync@example.com")
|
||||
|
||||
frappe.db.delete('Employee Attendance Log',{'employee':'EMP-00001'})
|
||||
employee_last_sync = get_employee_attendance_log_last_sync(employee, hr_settings)
|
||||
self.assertEqual(employee_last_sync, None)
|
||||
|
||||
employee_last_sync = get_employee_attendance_log_last_sync(employee)
|
||||
self.assertEqual(employee_last_sync, doc.last_sync_of_attendance_log)
|
||||
|
||||
from erpnext.hr.doctype.employee_attendance_log.test_employee_attendance_log import make_attendance_log
|
||||
time_now = now_datetime()
|
||||
make_attendance_log(employee, time_now)
|
||||
employee_last_sync = get_employee_attendance_log_last_sync(employee)
|
||||
self.assertEqual(employee_last_sync, time_now)
|
||||
|
||||
def test_calculate_working_hours(self):
|
||||
check_in_out_type = ['Alternating entries as IN and OUT during the same shift',
|
||||
'Strictly based on Log Type in Employee Attendance Log']
|
||||
working_hours_calc_type = ['First Check-in and Last Check-out',
|
||||
'Every Valid Check-in and Check-out']
|
||||
logs_type_1 = [
|
||||
{'time':now_datetime()-timedelta(minutes=390)},
|
||||
{'time':now_datetime()-timedelta(minutes=300)},
|
||||
{'time':now_datetime()-timedelta(minutes=270)},
|
||||
{'time':now_datetime()-timedelta(minutes=90)},
|
||||
{'time':now_datetime()-timedelta(minutes=0)}
|
||||
]
|
||||
logs_type_2 = [
|
||||
{'time':now_datetime()-timedelta(minutes=390),'log_type':'OUT'},
|
||||
{'time':now_datetime()-timedelta(minutes=360),'log_type':'IN'},
|
||||
{'time':now_datetime()-timedelta(minutes=300),'log_type':'OUT'},
|
||||
{'time':now_datetime()-timedelta(minutes=290),'log_type':'IN'},
|
||||
{'time':now_datetime()-timedelta(minutes=260),'log_type':'OUT'},
|
||||
{'time':now_datetime()-timedelta(minutes=240),'log_type':'IN'},
|
||||
{'time':now_datetime()-timedelta(minutes=150),'log_type':'IN'},
|
||||
{'time':now_datetime()-timedelta(minutes=60),'log_type':'OUT'}
|
||||
]
|
||||
logs_type_1 = [frappe._dict(x) for x in logs_type_1]
|
||||
logs_type_2 = [frappe._dict(x) for x in logs_type_2]
|
||||
|
||||
from erpnext.hr.doctype.hr_settings.hr_settings import calculate_working_hours
|
||||
|
||||
working_hours = calculate_working_hours(logs_type_1,check_in_out_type[0],working_hours_calc_type[0])
|
||||
self.assertEqual(working_hours, 6.5)
|
||||
|
||||
working_hours = calculate_working_hours(logs_type_1,check_in_out_type[0],working_hours_calc_type[1])
|
||||
self.assertEqual(working_hours, 4.5)
|
||||
|
||||
working_hours = calculate_working_hours(logs_type_2,check_in_out_type[1],working_hours_calc_type[0])
|
||||
self.assertEqual(working_hours, 5)
|
||||
|
||||
working_hours = calculate_working_hours(logs_type_2,check_in_out_type[1],working_hours_calc_type[1])
|
||||
self.assertEqual(working_hours, 4.5)
|
||||
|
||||
|
||||
|
||||
pass
|
||||
|
@ -7,7 +7,8 @@ import frappe
|
||||
from frappe import _
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint, cstr, date_diff, flt, formatdate, getdate, now_datetime, nowdate
|
||||
from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee, is_holiday
|
||||
from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee
|
||||
from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday
|
||||
from datetime import timedelta, datetime
|
||||
|
||||
class OverlapError(frappe.ValidationError): pass
|
||||
@ -90,7 +91,7 @@ def get_employee_shift(employee, for_date=nowdate(), consider_default_shift=Fals
|
||||
:param consider_default_shift: If set to true, default shift is taken when no shift assignment is found.
|
||||
:param next_shift_direction: One of: None, 'forward', 'reverse'. Direction to look for next shift if shift not found on given date.
|
||||
"""
|
||||
default_shift = frappe.db.get_value('HR Settings', None, 'default_shift')
|
||||
default_shift = frappe.db.get_value('Employee', employee, 'default_shift')
|
||||
shift_type_name = frappe.db.get_value('Shift Assignment', {'employee':employee, 'date': for_date, 'docstatus': '1'}, 'shift_type')
|
||||
if not shift_type_name and consider_default_shift:
|
||||
shift_type_name = default_shift
|
||||
@ -98,7 +99,7 @@ def get_employee_shift(employee, for_date=nowdate(), consider_default_shift=Fals
|
||||
holiday_list_name = frappe.db.get_value('Shift Type', shift_type_name, 'holiday_list')
|
||||
if not holiday_list_name:
|
||||
holiday_list_name = get_holiday_list_for_employee(employee, False)
|
||||
if holiday_list_name and is_holiday(holiday_list_name, for_date, False):
|
||||
if holiday_list_name and is_holiday(holiday_list_name, for_date):
|
||||
shift_type_name = None
|
||||
|
||||
if not shift_type_name and next_shift_direction:
|
||||
@ -114,7 +115,7 @@ def get_employee_shift(employee, for_date=nowdate(), consider_default_shift=Fals
|
||||
break
|
||||
else:
|
||||
direction = '<' if next_shift_direction == 'reverse' else '>'
|
||||
dates = frappe.db.get_list('Shift Assignment',
|
||||
dates = frappe.db.get_all('Shift Assignment',
|
||||
'date',
|
||||
{'employee':employee, 'date':(direction, for_date), 'docstatus': '1'},
|
||||
as_list=True,
|
||||
@ -177,3 +178,32 @@ def get_shift_details(shift_type_name, for_date=nowdate()):
|
||||
'actual_start': actual_start,
|
||||
'actual_end': actual_end
|
||||
})
|
||||
|
||||
|
||||
def get_actual_start_end_datetime_of_shift(employee, for_datetime, consider_default_shift=False):
|
||||
"""Takes a datetime and returns the 'actual' start datetime and end datetime of the shift in which the timestamp belongs.
|
||||
Here 'actual' means - taking in to account the "begin_check_in_before_shift_start_time" and "allow_check_out_after_shift_end_time".
|
||||
None is returned if the timestamp is outside any actual shift timings.
|
||||
Shift Details is also returned(current/upcoming i.e. if timestamp not in any actual shift then details of next shift returned)
|
||||
"""
|
||||
actual_shift_start = actual_shift_end = shift_details = None
|
||||
shift_timings_as_per_timestamp = get_employee_shift_timings(employee, for_datetime, consider_default_shift)
|
||||
timestamp_list = []
|
||||
for shift in shift_timings_as_per_timestamp:
|
||||
if shift:
|
||||
timestamp_list.extend([shift.actual_start, shift.actual_end])
|
||||
else:
|
||||
timestamp_list.extend([None, None])
|
||||
timestamp_index = None
|
||||
for index, timestamp in enumerate(timestamp_list):
|
||||
if timestamp and for_datetime <= timestamp:
|
||||
timestamp_index = index
|
||||
break
|
||||
if timestamp_index and timestamp_index%2 == 1:
|
||||
shift_details = shift_timings_as_per_timestamp[int((timestamp_index-1)/2)]
|
||||
actual_shift_start = shift_details.actual_start
|
||||
actual_shift_end = shift_details.actual_end
|
||||
elif timestamp_index:
|
||||
shift_details = shift_timings_as_per_timestamp[int(timestamp_index/2)]
|
||||
|
||||
return actual_shift_start, actual_shift_end, shift_details
|
||||
|
@ -18,6 +18,8 @@
|
||||
"column_break_10",
|
||||
"working_hours_threshold_for_half_day",
|
||||
"working_hours_threshold_for_absent",
|
||||
"process_attendance_after",
|
||||
"last_sync_of_attendance_log",
|
||||
"grace_period_settings_auto_attendance_section",
|
||||
"enable_entry_grace_period",
|
||||
"late_entry_grace_period",
|
||||
@ -185,9 +187,21 @@
|
||||
"fieldname": "enable_auto_attendance",
|
||||
"fieldtype": "Check",
|
||||
"label": "Enable Auto Attendance"
|
||||
},
|
||||
{
|
||||
"description": "Attendance will be marked automatically only after this date.",
|
||||
"fieldname": "process_attendance_after",
|
||||
"fieldtype": "Date",
|
||||
"label": "Process Attendance After"
|
||||
},
|
||||
{
|
||||
"description": "Last Known Successful Sync of Employee Attendance Log. Reset this only if you are sure that all Logs are synced from all the locations. Please don't modify this if you are unsure.",
|
||||
"fieldname": "last_sync_of_attendance_log",
|
||||
"fieldtype": "Datetime",
|
||||
"label": "Last Sync of Attendance Log"
|
||||
}
|
||||
],
|
||||
"modified": "2019-05-30 15:31:35.594990",
|
||||
"modified": "2019-05-31 16:02:44.272036",
|
||||
"modified_by": "Administrator",
|
||||
"module": "HR",
|
||||
"name": "Shift Type",
|
||||
|
@ -3,8 +3,115 @@
|
||||
# For license information, please see license.txt
|
||||
|
||||
from __future__ import unicode_literals
|
||||
import itertools
|
||||
from datetime import timedelta
|
||||
|
||||
import frappe
|
||||
from frappe.model.document import Document
|
||||
from frappe.utils import cint, getdate
|
||||
from erpnext.hr.doctype.shift_assignment.shift_assignment import get_actual_start_end_datetime_of_shift, get_employee_shift
|
||||
from erpnext.hr.doctype.employee_attendance_log.employee_attendance_log import mark_attendance_and_link_log, calculate_working_hours
|
||||
from erpnext.hr.doctype.attendance.attendance import mark_absent
|
||||
from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee
|
||||
|
||||
class ShiftType(Document):
|
||||
pass
|
||||
def process_auto_attendance(self):
|
||||
if not cint(self.enable_auto_attendance) or not self.process_attendance_after or not self.last_sync_of_attendance_log:
|
||||
return
|
||||
filters = {
|
||||
'skip_auto_attendance':'0',
|
||||
'attendance':('is', 'not set'),
|
||||
'time':('>=', self.process_attendance_after),
|
||||
'shift_actual_start': ('<', self.last_sync_of_attendance_log),
|
||||
'shift': self.name
|
||||
}
|
||||
logs = frappe.db.get_list('Employee Attendance Log', fields="*", filters=filters, order_by="employee,time")
|
||||
for key, group in itertools.groupby(logs, key=lambda x: (x['employee'], x['shift_actual_start'])):
|
||||
single_shift_logs = list(group)
|
||||
attendance_status, working_hours = self.get_attendance(single_shift_logs)
|
||||
mark_attendance_and_link_log(single_shift_logs, attendance_status, key[1].date(), working_hours, self.name)
|
||||
for employee in self.get_assigned_employee(self.process_attendance_after, True):
|
||||
self.mark_absent_for_dates_with_no_attendance(employee)
|
||||
|
||||
def get_attendance(self, logs):
|
||||
"""Return attendance_status, working_hours for a set of logs belonging to a single shift.
|
||||
Assumtion:
|
||||
1. These logs belongs to an single shift, single employee and is not in a holiday date.
|
||||
2. Logs are in chronological order
|
||||
"""
|
||||
total_working_hours = calculate_working_hours(logs, self.determine_check_in_and_check_out, self.working_hours_calculation_based_on)
|
||||
if self.working_hours_threshold_for_absent and total_working_hours < self.working_hours_threshold_for_absent:
|
||||
return 'Absent', total_working_hours
|
||||
if self.working_hours_threshold_for_half_day and total_working_hours < self.working_hours_threshold_for_half_day:
|
||||
return 'Half Day', total_working_hours
|
||||
return 'Present', total_working_hours
|
||||
|
||||
def mark_absent_for_dates_with_no_attendance(self, employee):
|
||||
"""Marks Absents for the given employee on working days in this shift which have no attendance marked.
|
||||
The Absent is marked starting from 'process_attendance_after' or employee creation date.
|
||||
"""
|
||||
date_of_joining, relieving_date, employee_creation = frappe.db.get_value("Employee", employee, ["date_of_joining", "relieving_date", "creation"])
|
||||
if not date_of_joining:
|
||||
date_of_joining = employee_creation.date()
|
||||
start_date = max(self.process_attendance_after, date_of_joining)
|
||||
actual_shift_datetime = get_actual_start_end_datetime_of_shift(employee, self.last_sync_of_attendance_log, True)
|
||||
last_shift_time = actual_shift_datetime[0] if actual_shift_datetime[0] else self.last_sync_of_attendance_log
|
||||
prev_shift = get_employee_shift(employee, last_shift_time.date()-timedelta(days=1), True, 'reverse')
|
||||
if prev_shift:
|
||||
end_date = min(prev_shift.start_datetime.date(), relieving_date) if relieving_date else prev_shift.start_datetime.date()
|
||||
else:
|
||||
return
|
||||
holiday_list_name = self.holiday_list
|
||||
if not holiday_list_name:
|
||||
holiday_list_name = get_holiday_list_for_employee(employee, False)
|
||||
for date in get_filtered_date_list(employee, start_date, end_date, holiday_list=holiday_list_name):
|
||||
shift_details = get_employee_shift(employee, date, True)
|
||||
if shift_details and shift_details.shift_type.name == self.name:
|
||||
mark_absent(employee, date)
|
||||
|
||||
def get_assigned_employee(self, from_date=None, consider_default_shift=False):
|
||||
filters = {'date':('>=', from_date), 'shift_type': self.name, 'docstatus': '1'}
|
||||
if not from_date:
|
||||
del filters['date']
|
||||
assigned_employees = frappe.get_all('Shift Assignment', 'employee', filters, as_list=True)
|
||||
assigned_employees = [x[0] for x in assigned_employees]
|
||||
|
||||
if consider_default_shift:
|
||||
filters = {'default_shift': self.name}
|
||||
default_shift_employees = frappe.get_all('Employee', 'name', filters, as_list=True)
|
||||
default_shift_employees = [x[0] for x in default_shift_employees]
|
||||
return list(set(assigned_employees+default_shift_employees))
|
||||
return assigned_employees
|
||||
|
||||
def process_auto_attendance_for_all_shifts():
|
||||
shift_list = frappe.get_all('Shift Type', 'name', {'enable_auto_attendance':'1'}, as_list=True)
|
||||
for shift in shift_list:
|
||||
doc = frappe.get_doc('Shift Type', shift[0])
|
||||
doc.process_auto_attendance()
|
||||
|
||||
def get_filtered_date_list(employee, start_date, end_date, filter_attendance=True, holiday_list=None):
|
||||
"""Returns a list of dates after removing the dates with attendance and holidays
|
||||
"""
|
||||
base_dates_query = """select adddate(%(start_date)s, t2.i*100 + t1.i*10 + t0.i) selected_date from
|
||||
(select 0 i union select 1 union select 2 union select 3 union select 4 union select 5 union select 6 union select 7 union select 8 union select 9) t0,
|
||||
(select 0 i union select 1 union select 2 union select 3 union select 4 union select 5 union select 6 union select 7 union select 8 union select 9) t1,
|
||||
(select 0 i union select 1 union select 2 union select 3 union select 4 union select 5 union select 6 union select 7 union select 8 union select 9) t2"""
|
||||
condition_query = ''
|
||||
if filter_attendance:
|
||||
condition_query += """ and a.selected_date not in (
|
||||
select attendance_date from `tabAttendance`
|
||||
where docstatus = '1' and employee = %(employee)s
|
||||
and attendance_date between %(start_date)s and %(end_date)s)"""
|
||||
if holiday_list:
|
||||
condition_query += """ and a.selected_date not in (
|
||||
select holiday_date from `tabHoliday` where parenttype = 'Holiday List' and
|
||||
parentfield = 'holidays' and parent = %(holiday_list)s
|
||||
and holiday_date between %(start_date)s and %(end_date)s)"""
|
||||
|
||||
dates = frappe.db.sql("""select * from
|
||||
({base_dates_query}) as a
|
||||
where a.selected_date <= %(end_date)s {condition_query}
|
||||
""".format(base_dates_query=base_dates_query, condition_query=condition_query),
|
||||
{"employee":employee, "start_date":start_date, "end_date":end_date, "holiday_list":holiday_list}, as_list=True)
|
||||
|
||||
return [getdate(date[0]) for date in dates]
|
||||
|
@ -11,7 +11,7 @@ from frappe.utils import (flt, getdate, get_url, now,
|
||||
from erpnext.controllers.queries import get_filters_cond
|
||||
from frappe.desk.reportview import get_match_cond
|
||||
from erpnext.hr.doctype.daily_work_summary.daily_work_summary import get_users_email
|
||||
from erpnext.hr.doctype.daily_work_summary_group.daily_work_summary_group import is_holiday_today
|
||||
from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday
|
||||
from frappe.model.document import Document
|
||||
|
||||
class Project(Document):
|
||||
@ -530,7 +530,7 @@ def get_projects_for_collect_progress(frequency, fields):
|
||||
def send_project_update_email_to_users(project):
|
||||
doc = frappe.get_doc('Project', project)
|
||||
|
||||
if is_holiday_today(doc.holiday_list) or not doc.users: return
|
||||
if is_holiday(doc.holiday_list) or not doc.users: return
|
||||
|
||||
project_update = frappe.get_doc({
|
||||
"doctype" : "Project Update",
|
||||
|
@ -19,9 +19,6 @@ class OverlapError(frappe.ValidationError): pass
|
||||
class OverWorkLoggedError(frappe.ValidationError): pass
|
||||
|
||||
class Timesheet(Document):
|
||||
def onload(self):
|
||||
self.get("__onload").maintain_bill_work_hours_same = frappe.db.get_single_value('HR Settings', 'maintain_bill_work_hours_same')
|
||||
|
||||
def validate(self):
|
||||
self.set_employee_name()
|
||||
self.set_status()
|
||||
|
Loading…
x
Reference in New Issue
Block a user