From 3711119a665fa924c48ec98de2984e5c0a411823 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 17 Feb 2022 18:32:48 +0530 Subject: [PATCH 01/31] refactor: overlapping shifts validation - convert raw query to frappe.qb - check for overlapping timings if dates overlap - translation friendly error messages with link to overlapping doc --- .../shift_assignment/shift_assignment.py | 116 ++++++++++-------- 1 file changed, 66 insertions(+), 50 deletions(-) diff --git a/erpnext/hr/doctype/shift_assignment/shift_assignment.py b/erpnext/hr/doctype/shift_assignment/shift_assignment.py index 5a1248698c..f51a860c92 100644 --- a/erpnext/hr/doctype/shift_assignment/shift_assignment.py +++ b/erpnext/hr/doctype/shift_assignment/shift_assignment.py @@ -7,79 +7,95 @@ from datetime import datetime, timedelta import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import cstr, getdate, now_datetime, nowdate +from frappe.query_builder import Criterion +from frappe.utils import cstr, get_link_to_form, getdate, now_datetime, nowdate from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday from erpnext.hr.utils import validate_active_employee +class OverlappingShiftError(frappe.ValidationError): + pass class ShiftAssignment(Document): def validate(self): validate_active_employee(self.employee) - self.validate_overlapping_dates() + self.validate_overlapping_shifts() if self.end_date: self.validate_from_to_dates("start_date", "end_date") - def validate_overlapping_dates(self): + def validate_overlapping_shifts(self): + overlapping_dates = self.get_overlapping_dates() + if len(overlapping_dates): + # if dates are overlapping, check if timings are overlapping, else allow + overlapping_timings = self.has_overlapping_timings(overlapping_dates[0].shift_type) + if overlapping_timings: + self.throw_overlap_error(overlapping_dates[0]) + + def get_overlapping_dates(self): if not self.name: self.name = "New Shift Assignment" - condition = """and ( - end_date is null - or - %(start_date)s between start_date and end_date - """ - - if self.end_date: - condition += """ or - %(end_date)s between start_date and end_date - or - start_date between %(start_date)s and %(end_date)s - ) """ - else: - condition += """ ) """ - - assigned_shifts = frappe.db.sql( - """ - select name, shift_type, start_date ,end_date, docstatus, status - from `tabShift Assignment` - where - employee=%(employee)s and docstatus = 1 - and name != %(name)s - and status = "Active" - {0} - """.format( - condition - ), - { - "employee": self.employee, - "shift_type": self.shift_type, - "start_date": self.start_date, - "end_date": self.end_date, - "name": self.name, - }, - as_dict=1, + shift = frappe.qb.DocType("Shift Assignment") + query = ( + frappe.qb.from_(shift) + .select(shift.name, shift.shift_type, shift.start_date, shift.end_date, shift.docstatus, shift.status) + .where( + (shift.employee == self.employee) + & (shift.docstatus == 1) + & (shift.name != self.name) + & (shift.status == "Active") + ) ) - if len(assigned_shifts): - self.throw_overlap_error(assigned_shifts[0]) + if self.end_date: + query = query.where( + Criterion.any([ + Criterion.any([ + shift.end_date.isnull(), + ((self.start_date >= shift.start_date) & (self.start_date <= shift.end_date)) + ]), + Criterion.any([ + ((self.end_date >= shift.start_date) & (self.end_date <= shift.end_date)), + shift.start_date.between(self.start_date, self.end_date) + ]) + ]) + ) + else: + query = query.where( + shift.end_date.isnull() + | ((self.start_date >= shift.start_date) & (self.start_date <= shift.end_date)) + ) + + return query.run(as_dict=True) + + def has_overlapping_timings(self, overlapping_shift): + curr_shift = frappe.db.get_value("Shift Type", self.shift_type, ["start_time", "end_time"], as_dict=True) + overlapping_shift = frappe.db.get_value("Shift Type", overlapping_shift, ["start_time", "end_time"], as_dict=True) + + if ((curr_shift.start_time > overlapping_shift.start_time and curr_shift.start_time < overlapping_shift.end_time) or + (curr_shift.end_time > overlapping_shift.start_time and curr_shift.end_time < overlapping_shift.end_time) or + (curr_shift.start_time <= overlapping_shift.start_time and curr_shift.end_time >= overlapping_shift.end_time)): + return True + return False def throw_overlap_error(self, shift_details): shift_details = frappe._dict(shift_details) + msg = None if shift_details.docstatus == 1 and shift_details.status == "Active": - msg = _("Employee {0} already has Active Shift {1}: {2}").format( - frappe.bold(self.employee), frappe.bold(self.shift_type), frappe.bold(shift_details.name) - ) - if shift_details.start_date: - msg += _(" from {0}").format(getdate(self.start_date).strftime("%d-%m-%Y")) - title = "Ongoing Shift" - if shift_details.end_date: - msg += _(" to {0}").format(getdate(self.end_date).strftime("%d-%m-%Y")) - title = "Active Shift" + if shift_details.start_date and shift_details.end_date: + msg = _("Employee {0} already has an active Shift {1}: {2} from {3} to {4}").format(frappe.bold(self.employee), frappe.bold(self.shift_type), + get_link_to_form("Shift Assignment", shift_details.name), + getdate(self.start_date).strftime("%d-%m-%Y"), + getdate(self.end_date).strftime("%d-%m-%Y")) + else: + msg = _("Employee {0} already has an active Shift {1}: {2} from {3}").format(frappe.bold(self.employee), frappe.bold(self.shift_type), + get_link_to_form("Shift Assignment", shift_details.name), + getdate(self.start_date).strftime("%d-%m-%Y")) + if msg: - frappe.throw(msg, title=title) + frappe.throw(msg, title=_("Overlapping Shifts"), exc=OverlappingShiftError) @frappe.whitelist() From 625a9f69f592be8c50c9b1bd1a16e0b7b9157988 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 20 Feb 2022 22:10:52 +0530 Subject: [PATCH 02/31] refactor: consider timeslots in `get_employee_shift` --- .../shift_assignment/shift_assignment.py | 245 ++++++++++-------- 1 file changed, 133 insertions(+), 112 deletions(-) diff --git a/erpnext/hr/doctype/shift_assignment/shift_assignment.py b/erpnext/hr/doctype/shift_assignment/shift_assignment.py index f51a860c92..86564e012b 100644 --- a/erpnext/hr/doctype/shift_assignment/shift_assignment.py +++ b/erpnext/hr/doctype/shift_assignment/shift_assignment.py @@ -7,7 +7,7 @@ from datetime import datetime, timedelta import frappe from frappe import _ from frappe.model.document import Document -from frappe.query_builder import Criterion +from frappe.query_builder import Criterion, Column from frappe.utils import cstr, get_link_to_form, getdate, now_datetime, nowdate from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee @@ -171,102 +171,124 @@ def get_shift_type_timing(shift_types): return shift_timing_map -def get_employee_shift( - employee, for_date=None, consider_default_shift=False, next_shift_direction=None -): +def get_shift_for_time(shifts, for_timestamp): + for entry in shifts: + shift_details = get_shift_details(entry.shift_type, for_date=for_timestamp.date()) + if shift_details.actual_start <= for_timestamp <= shift_details.actual_end: + return shift_details + + +def get_shifts_for_date(employee, for_timestamp): + assignment = frappe.qb.DocType('Shift Assignment') + + return ( + frappe.qb.from_(assignment) + .select(assignment.name, assignment.shift_type) + .where( + (assignment.employee == employee) + & (assignment.docstatus == 1) + & (assignment.status == 'Active') + & (assignment.start_date <= getdate(for_timestamp.date())) + & ( + Criterion.any([ + assignment.end_date.isnull(), + (assignment.end_date.isnotnull() & (getdate(for_timestamp.date()) >= assignment.end_date)) + ]) + ) + ) + ).run(as_dict=True) + + +def get_shift_for_timestamp(employee, for_timestamp): + shifts = get_shifts_for_date(employee, for_timestamp) + if shifts: + return get_shift_for_time(shifts, for_timestamp) + return None + + +def get_employee_shift(employee, for_timestamp=None, consider_default_shift=False, next_shift_direction=None): """Returns a Shift Type for the given employee on the given date. (excluding the holidays) :param employee: Employee for which shift is required. - :param for_date: Date on which shift are required + :param for_timestamp: DateTime on which shift is required :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. """ - if for_date is None: - for_date = nowdate() - default_shift = frappe.db.get_value("Employee", employee, "default_shift") - shift_type_name = None - shift_assignment_details = frappe.db.get_value( - "Shift Assignment", - {"employee": employee, "start_date": ("<=", for_date), "docstatus": "1", "status": "Active"}, - ["shift_type", "end_date"], - ) + if for_timestamp is None: + for_timestamp = now_datetime() - if shift_assignment_details: - shift_type_name = shift_assignment_details[0] + shift_details = get_shift_for_timestamp(employee, for_timestamp) - # if end_date present means that shift is over after end_date else it is a ongoing shift. - if shift_assignment_details[1] and for_date >= shift_assignment_details[1]: - shift_type_name = None + # if shift assignment is not found, consider default shift + default_shift = frappe.db.get_value('Employee', employee, 'default_shift') + if not shift_details and consider_default_shift: + shift_details = get_shift_details(default_shift, for_timestamp.date()) - if not shift_type_name and consider_default_shift: - shift_type_name = default_shift - if shift_type_name: - 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): - shift_type_name = None + # if its a holiday, reset + if shift_details and is_holiday_date(employee, shift_details): + shift_details = None - if not shift_type_name and next_shift_direction: - MAX_DAYS = 366 - if consider_default_shift and default_shift: - direction = -1 if next_shift_direction == "reverse" else +1 - for i in range(MAX_DAYS): - date = for_date + timedelta(days=direction * (i + 1)) - shift_details = get_employee_shift(employee, date, consider_default_shift, None) + # if no shift is found, find next or prev shift based on direction + if not shift_details and next_shift_direction: + shift_details = get_prev_or_next_shift(employee, for_timestamp, consider_default_shift, default_shift, next_shift_direction) + + return shift_details + + +def get_prev_or_next_shift(employee, for_timestamp, consider_default_shift, default_shift, next_shift_direction): + MAX_DAYS = 366 + shift_details = None + + if consider_default_shift and default_shift: + direction = -1 if next_shift_direction == 'reverse' else 1 + for i in range(MAX_DAYS): + date = for_timestamp + timedelta(days=direction*(i+1)) + shift_details = get_employee_shift(employee, date, consider_default_shift, None) + if shift_details: + break + else: + direction = '<' if next_shift_direction == 'reverse' else '>' + sort_order = 'desc' if next_shift_direction == 'reverse' else 'asc' + dates = frappe.db.get_all('Shift Assignment', + ['start_date', 'end_date'], + {'employee':employee, 'start_date': (direction, for_timestamp.date()), 'docstatus': '1', "status": "Active"}, + as_list=True, + limit=MAX_DAYS, order_by='start_date ' + sort_order) + + if dates: + for date in dates: + if date[1] and date[1] < for_timestamp.date(): + continue + shift_details = get_employee_shift(employee, datetime.combine(date, for_timestamp.time()), consider_default_shift, None) if shift_details: - shift_type_name = shift_details.shift_type.name - for_date = date break - else: - direction = "<" if next_shift_direction == "reverse" else ">" - sort_order = "desc" if next_shift_direction == "reverse" else "asc" - dates = frappe.db.get_all( - "Shift Assignment", - ["start_date", "end_date"], - { - "employee": employee, - "start_date": (direction, for_date), - "docstatus": "1", - "status": "Active", - }, - as_list=True, - limit=MAX_DAYS, - order_by="start_date " + sort_order, - ) - if dates: - for date in dates: - if date[1] and date[1] < for_date: - continue - shift_details = get_employee_shift(employee, date[0], consider_default_shift, None) - if shift_details: - shift_type_name = shift_details.shift_type.name - for_date = date[0] - break + return shift_details - return get_shift_details(shift_type_name, for_date) + +def is_holiday_date(employee, shift_details): + holiday_list_name = frappe.db.get_value('Shift Type', shift_details.shift_type.name, 'holiday_list') + + if not holiday_list_name: + holiday_list_name = get_holiday_list_for_employee(employee, False) + + return holiday_list_name and is_holiday(holiday_list_name, shift_details.start_datetime.date()) def get_employee_shift_timings(employee, for_timestamp=None, consider_default_shift=False): """Returns previous shift, current/upcoming shift, next_shift for the given timestamp and employee""" if for_timestamp is None: for_timestamp = now_datetime() + # write and verify a test case for midnight shift. prev_shift = curr_shift = next_shift = None - curr_shift = get_employee_shift(employee, for_timestamp.date(), consider_default_shift, "forward") + curr_shift = get_employee_shift(employee, for_timestamp, consider_default_shift, 'forward') if curr_shift: - next_shift = get_employee_shift( - employee, - curr_shift.start_datetime.date() + timedelta(days=1), - consider_default_shift, - "forward", - ) - prev_shift = get_employee_shift( - employee, for_timestamp.date() + timedelta(days=-1), consider_default_shift, "reverse" - ) + next_shift = get_employee_shift(employee, curr_shift.start_datetime + timedelta(days=1), consider_default_shift, 'forward') + prev_shift = get_employee_shift(employee, for_timestamp + timedelta(days=-1), consider_default_shift, 'reverse') if curr_shift: + # adjust actual start and end times if they are overlapping with grace period (before start and after end) if prev_shift: curr_shift.actual_start = ( prev_shift.end_datetime @@ -292,6 +314,38 @@ def get_employee_shift_timings(employee, for_timestamp=None, consider_default_sh return prev_shift, curr_shift, next_shift +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 + + def get_shift_details(shift_type_name, for_date=None): """Returns Shift Details which contain some additional information as described below. 'shift_details' contains the following keys: @@ -319,43 +373,10 @@ def get_shift_details(shift_type_name, for_date=None): ) actual_end = end_datetime + timedelta(minutes=shift_type.allow_check_out_after_shift_end_time) - return frappe._dict( - { - "shift_type": shift_type, - "start_datetime": start_datetime, - "end_datetime": end_datetime, - "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 + return frappe._dict({ + 'shift_type': shift_type, + 'start_datetime': start_datetime, + 'end_datetime': end_datetime, + 'actual_start': actual_start, + 'actual_end': actual_end + }) From ace3f8a02378454028881d7d7a70bb2af30da78a Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 20 Feb 2022 22:11:37 +0530 Subject: [PATCH 03/31] fix: handle shift grace overlap while finding current shift --- .../shift_assignment/shift_assignment.py | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/erpnext/hr/doctype/shift_assignment/shift_assignment.py b/erpnext/hr/doctype/shift_assignment/shift_assignment.py index 86564e012b..88aeb4cbc0 100644 --- a/erpnext/hr/doctype/shift_assignment/shift_assignment.py +++ b/erpnext/hr/doctype/shift_assignment/shift_assignment.py @@ -8,7 +8,7 @@ import frappe from frappe import _ from frappe.model.document import Document from frappe.query_builder import Criterion, Column -from frappe.utils import cstr, get_link_to_form, getdate, now_datetime, nowdate +from frappe.utils import cstr, get_link_to_form, get_datetime, getdate, now_datetime, nowdate from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday @@ -172,10 +172,33 @@ def get_shift_type_timing(shift_types): def get_shift_for_time(shifts, for_timestamp): + valid_shifts = [] for entry in shifts: shift_details = get_shift_details(entry.shift_type, for_date=for_timestamp.date()) - if shift_details.actual_start <= for_timestamp <= shift_details.actual_end: - return shift_details + + if get_datetime(shift_details.actual_start) <= get_datetime(for_timestamp) <= get_datetime(shift_details.actual_end): + valid_shifts.append(shift_details) + + valid_shifts.sort(key=lambda x: x['actual_start']) + + if len(valid_shifts) > 1: + for i in range(len(valid_shifts) - 1): + # comparing 2 consecutive shifts and adjusting start and end times + # if they are overlapping within grace period + curr_shift = valid_shifts[i] + next_shift = valid_shifts[i + 1] + + if curr_shift and next_shift: + next_shift.actual_start = curr_shift.end_datetime if next_shift.actual_start < curr_shift.end_datetime else next_shift.actual_start + curr_shift.actual_end = next_shift.actual_start if curr_shift.actual_end > next_shift.actual_start else curr_shift.actual_end + + valid_shifts[i] = curr_shift + valid_shifts[i + 1] = next_shift + + exact_shift = get_exact_shift(valid_shifts, for_timestamp) + return exact_shift and exact_shift[2] + + return valid_shifts and valid_shifts[0] def get_shifts_for_date(employee, for_timestamp): @@ -251,7 +274,7 @@ def get_prev_or_next_shift(employee, for_timestamp, consider_default_shift, defa sort_order = 'desc' if next_shift_direction == 'reverse' else 'asc' dates = frappe.db.get_all('Shift Assignment', ['start_date', 'end_date'], - {'employee':employee, 'start_date': (direction, for_timestamp.date()), 'docstatus': '1', "status": "Active"}, + {'employee': employee, 'start_date': (direction, for_timestamp.date()), 'docstatus': 1, 'status': 'Active'}, as_list=True, limit=MAX_DAYS, order_by='start_date ' + sort_order) @@ -259,7 +282,7 @@ def get_prev_or_next_shift(employee, for_timestamp, consider_default_shift, defa for date in dates: if date[1] and date[1] < for_timestamp.date(): continue - shift_details = get_employee_shift(employee, datetime.combine(date, for_timestamp.time()), consider_default_shift, None) + shift_details = get_employee_shift(employee, datetime.combine(date[0], for_timestamp.time()), consider_default_shift, None) if shift_details: break @@ -320,11 +343,15 @@ def get_actual_start_end_datetime_of_shift(employee, for_datetime, consider_defa 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) + return get_exact_shift(shift_timings_as_per_timestamp, for_datetime) + + +def get_exact_shift(shifts, for_datetime): + actual_shift_start = actual_shift_end = shift_details = None timestamp_list = [] - for shift in shift_timings_as_per_timestamp: + for shift in shifts: if shift: timestamp_list.extend([shift.actual_start, shift.actual_end]) else: @@ -337,11 +364,11 @@ def get_actual_start_end_datetime_of_shift(employee, for_datetime, consider_defa break if timestamp_index and timestamp_index%2 == 1: - shift_details = shift_timings_as_per_timestamp[int((timestamp_index-1)/2)] + shift_details = shifts[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)] + shift_details = shifts[int(timestamp_index/2)] return actual_shift_start, actual_shift_end, shift_details From 62e72752dce92792166f9b734c2306adb4b41147 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 21 Feb 2022 01:02:15 +0530 Subject: [PATCH 04/31] refactor: handle shifts spanning over 2 different days --- .../shift_assignment/shift_assignment.py | 45 ++++++++++++------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/erpnext/hr/doctype/shift_assignment/shift_assignment.py b/erpnext/hr/doctype/shift_assignment/shift_assignment.py index 88aeb4cbc0..6912c76e35 100644 --- a/erpnext/hr/doctype/shift_assignment/shift_assignment.py +++ b/erpnext/hr/doctype/shift_assignment/shift_assignment.py @@ -8,7 +8,7 @@ import frappe from frappe import _ from frappe.model.document import Document from frappe.query_builder import Criterion, Column -from frappe.utils import cstr, get_link_to_form, get_datetime, getdate, now_datetime, nowdate +from frappe.utils import cstr, get_link_to_form, get_datetime, get_time, getdate, now_datetime, nowdate from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday @@ -174,7 +174,7 @@ def get_shift_type_timing(shift_types): def get_shift_for_time(shifts, for_timestamp): valid_shifts = [] for entry in shifts: - shift_details = get_shift_details(entry.shift_type, for_date=for_timestamp.date()) + shift_details = get_shift_details(entry.shift_type, for_timestamp=for_timestamp) if get_datetime(shift_details.actual_start) <= get_datetime(for_timestamp) <= get_datetime(shift_details.actual_end): valid_shifts.append(shift_details) @@ -245,7 +245,7 @@ def get_employee_shift(employee, for_timestamp=None, consider_default_shift=Fals # if shift assignment is not found, consider default shift default_shift = frappe.db.get_value('Employee', employee, 'default_shift') if not shift_details and consider_default_shift: - shift_details = get_shift_details(default_shift, for_timestamp.date()) + shift_details = get_shift_details(default_shift, for_timestamp) # if its a holiday, reset if shift_details and is_holiday_date(employee, shift_details): @@ -373,7 +373,7 @@ def get_exact_shift(shifts, for_datetime): return actual_shift_start, actual_shift_end, shift_details -def get_shift_details(shift_type_name, for_date=None): +def get_shift_details(shift_type_name, for_timestamp=None): """Returns Shift Details which contain some additional information as described below. 'shift_details' contains the following keys: 'shift_type' - Object of DocType Shift Type, @@ -383,21 +383,34 @@ def get_shift_details(shift_type_name, for_date=None): 'actual_end' - datetime of shift end after adding 'allow_check_out_after_shift_end_time'(None is returned if this is zero) :param shift_type_name: shift type name for which shift_details is required. - :param for_date: Date on which shift_details are required + :param for_timestamp: DateTime value on which shift_details are required """ if not shift_type_name: return None - if not for_date: - for_date = nowdate() - shift_type = frappe.get_doc("Shift Type", shift_type_name) - start_datetime = datetime.combine(for_date, datetime.min.time()) + shift_type.start_time - for_date = ( - for_date + timedelta(days=1) if shift_type.start_time > shift_type.end_time else for_date - ) - end_datetime = datetime.combine(for_date, datetime.min.time()) + shift_type.end_time - actual_start = start_datetime - timedelta( - minutes=shift_type.begin_check_in_before_shift_start_time - ) + if not for_timestamp: + for_timestamp = now_datetime() + + shift_type = frappe.get_doc('Shift Type', shift_type_name) + shift_actual_start = shift_type.start_time - timedelta(minutes=shift_type.begin_check_in_before_shift_start_time) + + if shift_type.start_time > shift_type.end_time: + # shift spans accross 2 different days + if get_time(for_timestamp.time()) >= get_time(shift_actual_start): + # if for_timestamp is greater than start time, its in the first day + start_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.start_time + for_timestamp = for_timestamp + timedelta(days=1) + end_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.end_time + elif get_time(for_timestamp.time()) < get_time(shift_actual_start): + # if for_timestamp is less than start time, its in the second day + end_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.end_time + for_timestamp = for_timestamp + timedelta(days=-1) + start_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.start_time + else: + # start and end times fall on the same day + start_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.start_time + end_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.end_time + + actual_start = start_datetime - timedelta(minutes=shift_type.begin_check_in_before_shift_start_time) actual_end = end_datetime + timedelta(minutes=shift_type.allow_check_out_after_shift_end_time) return frappe._dict({ From e7cbb5fe6bcb0a364df920ca8e2d1212f9664edd Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 21 Feb 2022 13:47:42 +0530 Subject: [PATCH 05/31] fix: fetching shift on timing boundaries --- .../shift_assignment/shift_assignment.py | 30 +++++++++++-------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/erpnext/hr/doctype/shift_assignment/shift_assignment.py b/erpnext/hr/doctype/shift_assignment/shift_assignment.py index 6912c76e35..d4f5f0e789 100644 --- a/erpnext/hr/doctype/shift_assignment/shift_assignment.py +++ b/erpnext/hr/doctype/shift_assignment/shift_assignment.py @@ -324,24 +324,17 @@ def get_employee_shift_timings(employee, for_timestamp=None, consider_default_sh else prev_shift.actual_end ) if next_shift: - next_shift.actual_start = ( - curr_shift.end_datetime - if next_shift.actual_start < curr_shift.end_datetime - else next_shift.actual_start - ) - curr_shift.actual_end = ( - next_shift.actual_start - if curr_shift.actual_end > next_shift.actual_start - else curr_shift.actual_end - ) + next_shift.actual_start = curr_shift.end_datetime if next_shift.actual_start < curr_shift.end_datetime else next_shift.actual_start + curr_shift.actual_end = next_shift.actual_start if curr_shift.actual_end > next_shift.actual_start else curr_shift.actual_end + return prev_shift, curr_shift, next_shift 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". + Here 'actual' means - taking into 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) + Shift Details are also returned(current/upcoming i.e. if timestamp not in any actual shift then details of next shift returned) """ shift_timings_as_per_timestamp = get_employee_shift_timings(employee, for_datetime, consider_default_shift) return get_exact_shift(shift_timings_as_per_timestamp, for_datetime) @@ -359,8 +352,19 @@ def get_exact_shift(shifts, for_datetime): timestamp_index = None for index, timestamp in enumerate(timestamp_list): - if timestamp and for_datetime <= timestamp: + if not timestamp: + continue + + if for_datetime < timestamp: timestamp_index = index + elif for_datetime == timestamp: + # on timestamp boundary + if index%2 == 1: + timestamp_index = index + else: + timestamp_index = index + 1 + + if timestamp_index: break if timestamp_index and timestamp_index%2 == 1: From f6a12a902f0fa8933bc584a8c70b958e5cf4045d Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 21 Feb 2022 16:26:25 +0530 Subject: [PATCH 06/31] refactor: rewrite docstrings and add type hints for functions --- .../employee_checkin/employee_checkin.py | 30 ++--- .../shift_assignment/shift_assignment.py | 107 ++++++++++-------- erpnext/hr/doctype/shift_type/shift_type.py | 14 +-- 3 files changed, 74 insertions(+), 77 deletions(-) diff --git a/erpnext/hr/doctype/employee_checkin/employee_checkin.py b/erpnext/hr/doctype/employee_checkin/employee_checkin.py index 87f48b7e25..aafb2bb61e 100644 --- a/erpnext/hr/doctype/employee_checkin/employee_checkin.py +++ b/erpnext/hr/doctype/employee_checkin/employee_checkin.py @@ -30,27 +30,17 @@ class EmployeeCheckin(Document): ) 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 ( - shift_actual_timings[2].shift_type.determine_check_in_and_check_out - == "Strictly based on Log Type in Employee Checkin" - and not self.log_type - and not self.skip_auto_attendance - ): - frappe.throw( - _("Log Type is required for check-ins falling in the shift: {0}.").format( - shift_actual_timings[2].shift_type.name - ) - ) + shift_actual_timings = get_actual_start_end_datetime_of_shift(self.employee, get_datetime(self.time), True) + if shift_actual_timings: + if shift_actual_timings.shift_type.determine_check_in_and_check_out == 'Strictly based on Log Type in Employee Checkin' \ + and not self.log_type and not self.skip_auto_attendance: + frappe.throw(_('Log Type is required for check-ins falling in the shift: {0}.').format(shift_actual_timings.shift_type.name)) 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 + self.shift = shift_actual_timings.shift_type.name + self.shift_actual_start = shift_actual_timings.actual_start + self.shift_actual_end = shift_actual_timings.actual_end + self.shift_start = shift_actual_timings.start_datetime + self.shift_end = shift_actual_timings.end_datetime else: self.shift = None diff --git a/erpnext/hr/doctype/shift_assignment/shift_assignment.py b/erpnext/hr/doctype/shift_assignment/shift_assignment.py index d4f5f0e789..702d3a2506 100644 --- a/erpnext/hr/doctype/shift_assignment/shift_assignment.py +++ b/erpnext/hr/doctype/shift_assignment/shift_assignment.py @@ -14,6 +14,9 @@ from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday from erpnext.hr.utils import validate_active_employee +from typing import Dict, List + + class OverlappingShiftError(frappe.ValidationError): pass @@ -171,8 +174,10 @@ def get_shift_type_timing(shift_types): return shift_timing_map -def get_shift_for_time(shifts, for_timestamp): +def get_shift_for_time(shifts: List[Dict], for_timestamp: datetime) -> Dict: + """Returns shift with details for given timestamp""" valid_shifts = [] + for entry in shifts: shift_details = get_shift_details(entry.shift_type, for_timestamp=for_timestamp) @@ -195,13 +200,13 @@ def get_shift_for_time(shifts, for_timestamp): valid_shifts[i] = curr_shift valid_shifts[i + 1] = next_shift - exact_shift = get_exact_shift(valid_shifts, for_timestamp) - return exact_shift and exact_shift[2] + return get_exact_shift(valid_shifts, for_timestamp) or {} - return valid_shifts and valid_shifts[0] + return (valid_shifts and valid_shifts[0]) or {} -def get_shifts_for_date(employee, for_timestamp): +def get_shifts_for_date(employee: str, for_timestamp: datetime) -> List[Dict[str, str]]: + """Returns list of shifts with details for given date""" assignment = frappe.qb.DocType('Shift Assignment') return ( @@ -222,14 +227,14 @@ def get_shifts_for_date(employee, for_timestamp): ).run(as_dict=True) -def get_shift_for_timestamp(employee, for_timestamp): +def get_shift_for_timestamp(employee: str, for_timestamp: datetime) -> Dict: shifts = get_shifts_for_date(employee, for_timestamp) if shifts: return get_shift_for_time(shifts, for_timestamp) - return None + return {} -def get_employee_shift(employee, for_timestamp=None, consider_default_shift=False, next_shift_direction=None): +def get_employee_shift(employee: str, for_timestamp: datetime = None, consider_default_shift: bool = False, next_shift_direction: str = None) -> Dict: """Returns a Shift Type for the given employee on the given date. (excluding the holidays) :param employee: Employee for which shift is required. @@ -251,16 +256,18 @@ def get_employee_shift(employee, for_timestamp=None, consider_default_shift=Fals if shift_details and is_holiday_date(employee, shift_details): shift_details = None - # if no shift is found, find next or prev shift based on direction + # if no shift is found, find next or prev shift assignment based on direction if not shift_details and next_shift_direction: shift_details = get_prev_or_next_shift(employee, for_timestamp, consider_default_shift, default_shift, next_shift_direction) - return shift_details + return shift_details or {} -def get_prev_or_next_shift(employee, for_timestamp, consider_default_shift, default_shift, next_shift_direction): +def get_prev_or_next_shift(employee: str, for_timestamp: datetime, consider_default_shift: bool, + default_shift: str, next_shift_direction: str) -> Dict: + """Returns a dict of shift details for the next or prev shift based on the next_shift_direction""" MAX_DAYS = 366 - shift_details = None + shift_details = {} if consider_default_shift and default_shift: direction = -1 if next_shift_direction == 'reverse' else 1 @@ -286,10 +293,10 @@ def get_prev_or_next_shift(employee, for_timestamp, consider_default_shift, defa if shift_details: break - return shift_details + return shift_details or {} -def is_holiday_date(employee, shift_details): +def is_holiday_date(employee: str, shift_details: Dict) -> bool: holiday_list_name = frappe.db.get_value('Shift Type', shift_details.shift_type.name, 'holiday_list') if not holiday_list_name: @@ -298,7 +305,7 @@ def is_holiday_date(employee, shift_details): return holiday_list_name and is_holiday(holiday_list_name, shift_details.start_datetime.date()) -def get_employee_shift_timings(employee, for_timestamp=None, consider_default_shift=False): +def get_employee_shift_timings(employee: str, for_timestamp: datetime = None, consider_default_shift: bool = False) -> List[Dict]: """Returns previous shift, current/upcoming shift, next_shift for the given timestamp and employee""" if for_timestamp is None: for_timestamp = now_datetime() @@ -330,18 +337,26 @@ def get_employee_shift_timings(employee, for_timestamp=None, consider_default_sh return prev_shift, curr_shift, next_shift -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 into 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 are also returned(current/upcoming i.e. if timestamp not in any actual shift then details of next shift returned) +def get_actual_start_end_datetime_of_shift(employee: str, for_timestamp: datetime, consider_default_shift: bool = False) -> Dict: """ - shift_timings_as_per_timestamp = get_employee_shift_timings(employee, for_datetime, consider_default_shift) - return get_exact_shift(shift_timings_as_per_timestamp, for_datetime) + Params: + employee (str): Employee name + for_timestamp (datetime, optional): Datetime value of checkin, if not provided considers current datetime + consider_default_shift (bool, optional): Flag (defaults to False) to specify whether to consider + default shift in employee master if no shift assignment is found + + Returns: + dict: Dict containing shift details with actual_start and actual_end datetime values + Here 'actual' means taking into account the "begin_check_in_before_shift_start_time" and "allow_check_out_after_shift_end_time". + Empty Dict is returned if the timestamp is outside any actual shift timings. + """ + shift_timings_as_per_timestamp = get_employee_shift_timings(employee, for_timestamp, consider_default_shift) + return get_exact_shift(shift_timings_as_per_timestamp, for_timestamp) -def get_exact_shift(shifts, for_datetime): - actual_shift_start = actual_shift_end = shift_details = None +def get_exact_shift(shifts: List, for_timestamp: datetime) -> Dict: + """Returns the shift details (dict) for the exact shift in which the 'for_timestamp' value falls among multiple shifts""" + shift_details = dict() timestamp_list = [] for shift in shifts: @@ -355,9 +370,9 @@ def get_exact_shift(shifts, for_datetime): if not timestamp: continue - if for_datetime < timestamp: + if for_timestamp < timestamp: timestamp_index = index - elif for_datetime == timestamp: + elif for_timestamp == timestamp: # on timestamp boundary if index%2 == 1: timestamp_index = index @@ -369,29 +384,28 @@ def get_exact_shift(shifts, for_datetime): if timestamp_index and timestamp_index%2 == 1: shift_details = shifts[int((timestamp_index-1)/2)] - actual_shift_start = shift_details.actual_start - actual_shift_end = shift_details.actual_end - elif timestamp_index: - shift_details = shifts[int(timestamp_index/2)] - return actual_shift_start, actual_shift_end, shift_details + return shift_details -def get_shift_details(shift_type_name, for_timestamp=None): - """Returns Shift Details which contain some additional information as described below. - 'shift_details' contains the following keys: - 'shift_type' - Object of DocType Shift Type, - 'start_datetime' - Date and Time of shift start on given date, - 'end_datetime' - Date and Time of shift end on given date, - 'actual_start' - datetime of shift start after adding 'begin_check_in_before_shift_start_time', - 'actual_end' - datetime of shift end after adding 'allow_check_out_after_shift_end_time'(None is returned if this is zero) +def get_shift_details(shift_type_name: str, for_timestamp: datetime = None) -> Dict: + """ + Params: + shift_type_name (str): shift type name for which shift_details are required. + for_timestamp (datetime, optional): Datetime value of checkin, if not provided considers current datetime - :param shift_type_name: shift type name for which shift_details is required. - :param for_timestamp: DateTime value on which shift_details are required + Returns: + dict: Dict containing shift details with the following data: + 'shift_type' - Object of DocType Shift Type, + 'start_datetime' - datetime of shift start on given timestamp, + 'end_datetime' - datetime of shift end on given timestamp, + 'actual_start' - datetime of shift start after adding 'begin_check_in_before_shift_start_time', + 'actual_end' - datetime of shift end after adding 'allow_check_out_after_shift_end_time' (None is returned if this is zero) """ if not shift_type_name: - return None - if not for_timestamp: + return {} + + if for_timestamp is None: for_timestamp = now_datetime() shift_type = frappe.get_doc('Shift Type', shift_type_name) @@ -400,17 +414,18 @@ def get_shift_details(shift_type_name, for_timestamp=None): if shift_type.start_time > shift_type.end_time: # shift spans accross 2 different days if get_time(for_timestamp.time()) >= get_time(shift_actual_start): - # if for_timestamp is greater than start time, its in the first day + # if for_timestamp is greater than start time, it's within the first day start_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.start_time for_timestamp = for_timestamp + timedelta(days=1) end_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.end_time + elif get_time(for_timestamp.time()) < get_time(shift_actual_start): - # if for_timestamp is less than start time, its in the second day + # if for_timestamp is less than start time, it's within the second day end_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.end_time for_timestamp = for_timestamp + timedelta(days=-1) start_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.start_time else: - # start and end times fall on the same day + # start and end timings fall on the same day start_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.start_time end_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.end_time diff --git a/erpnext/hr/doctype/shift_type/shift_type.py b/erpnext/hr/doctype/shift_type/shift_type.py index 3f5cb222bf..e5a5565a2b 100644 --- a/erpnext/hr/doctype/shift_type/shift_type.py +++ b/erpnext/hr/doctype/shift_type/shift_type.py @@ -113,17 +113,9 @@ class ShiftType(Document): if not date_of_joining: date_of_joining = employee_creation.date() start_date = max(getdate(self.process_attendance_after), date_of_joining) - actual_shift_datetime = get_actual_start_end_datetime_of_shift( - employee, get_datetime(self.last_sync_of_checkin), True - ) - last_shift_time = ( - actual_shift_datetime[0] - if actual_shift_datetime[0] - else get_datetime(self.last_sync_of_checkin) - ) - prev_shift = get_employee_shift( - employee, last_shift_time.date() - timedelta(days=1), True, "reverse" - ) + actual_shift_datetime = get_actual_start_end_datetime_of_shift(employee, get_datetime(self.last_sync_of_checkin), True) + last_shift_time = actual_shift_datetime.actual_start if actual_shift_datetime else get_datetime(self.last_sync_of_checkin) + 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) From cb3b3300972d482d77caa013700524b78bbe9ac8 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 21 Feb 2022 19:04:09 +0530 Subject: [PATCH 07/31] refactor: Allow multiple attendance records creation for different shifts --- erpnext/hr/doctype/attendance/attendance.py | 94 +++++++++++++-------- 1 file changed, 57 insertions(+), 37 deletions(-) diff --git a/erpnext/hr/doctype/attendance/attendance.py b/erpnext/hr/doctype/attendance/attendance.py index 7f4bd83685..2d8bf15201 100644 --- a/erpnext/hr/doctype/attendance/attendance.py +++ b/erpnext/hr/doctype/attendance/attendance.py @@ -5,11 +5,15 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import cint, cstr, formatdate, get_datetime, getdate, nowdate +from frappe.utils import cint, cstr, formatdate, get_datetime, get_link_to_form, getdate, nowdate +from frappe.query_builder import Criterion from erpnext.hr.utils import get_holiday_dates_for_employee, validate_active_employee +class DuplicateAttendanceError(frappe.ValidationError): + pass + class Attendance(Document): def validate(self): from erpnext.controllers.status_updater import validate_status @@ -35,22 +39,12 @@ class Attendance(Document): frappe.throw(_("Attendance date can not be less than employee's joining date")) def validate_duplicate_record(self): - res = frappe.db.sql( - """ - select name from `tabAttendance` - where employee = %s - and attendance_date = %s - and name != %s - and docstatus != 2 - """, - (self.employee, getdate(self.attendance_date), self.name), - ) - if res: - frappe.throw( - _("Attendance for employee {0} is already marked for the date {1}").format( - frappe.bold(self.employee), frappe.bold(self.attendance_date) - ) - ) + duplicate = get_duplicate_attendance_record(self.employee, self.attendance_date, self.shift, self.name) + + if duplicate: + frappe.throw(_("Attendance for employee {0} is already marked for the date {1}: {2}").format( + frappe.bold(self.employee), frappe.bold(self.attendance_date), get_link_to_form("Attendance", duplicate[0].name)), + title=_("Duplicate Attendance"), exc=DuplicateAttendanceError) def validate_employee_status(self): if frappe.db.get_value("Employee", self.employee, "status") == "Inactive": @@ -103,6 +97,40 @@ class Attendance(Document): frappe.throw(_("Employee {0} is not active or does not exist").format(self.employee)) +def get_duplicate_attendance_record(employee, attendance_date, shift, name=None): + attendance = frappe.qb.DocType("Attendance") + query = ( + frappe.qb.from_(attendance) + .select(attendance.name) + .where( + (attendance.employee == employee) + & (attendance.docstatus < 2) + ) + ) + + if shift: + query = query.where( + Criterion.any([ + Criterion.all([ + ((attendance.shift.isnull()) | (attendance.shift == "")), + (attendance.attendance_date == attendance_date) + ]), + Criterion.all([ + ((attendance.shift.isnotnull()) | (attendance.shift != "")), + (attendance.attendance_date == attendance_date), + (attendance.shift == shift) + ]) + ]) + ) + else: + query = query.where((attendance.attendance_date == attendance_date)) + + if name: + query = query.where(attendance.name != name) + + return query.run(as_dict=True) + + @frappe.whitelist() def get_events(start, end, filters=None): events = [] @@ -139,26 +167,18 @@ def add_attendance(events, start, end, conditions=None): if e not in events: events.append(e) - -def mark_attendance( - employee, attendance_date, status, shift=None, leave_type=None, ignore_validate=False -): - if not frappe.db.exists( - "Attendance", - {"employee": employee, "attendance_date": attendance_date, "docstatus": ("!=", "2")}, - ): - company = frappe.db.get_value("Employee", employee, "company") - attendance = frappe.get_doc( - { - "doctype": "Attendance", - "employee": employee, - "attendance_date": attendance_date, - "status": status, - "company": company, - "shift": shift, - "leave_type": leave_type, - } - ) +def mark_attendance(employee, attendance_date, status, shift=None, leave_type=None, ignore_validate=False): + if not get_duplicate_attendance_record(employee, attendance_date, shift): + company = frappe.db.get_value('Employee', employee, 'company') + attendance = frappe.get_doc({ + 'doctype': 'Attendance', + 'employee': employee, + 'attendance_date': attendance_date, + 'status': status, + 'company': company, + 'shift': shift, + 'leave_type': leave_type + }) attendance.flags.ignore_validate = ignore_validate attendance.insert() attendance.submit() From 742c8f07902df13a49ea6b947bfed36bb90a1677 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 22 Feb 2022 17:30:28 +0530 Subject: [PATCH 08/31] feat: auto attendance marking for multiple shifts on the same day --- .../employee_checkin/employee_checkin.py | 10 ++-- erpnext/hr/doctype/shift_type/shift_type.py | 54 +++++++------------ 2 files changed, 23 insertions(+), 41 deletions(-) diff --git a/erpnext/hr/doctype/employee_checkin/employee_checkin.py b/erpnext/hr/doctype/employee_checkin/employee_checkin.py index aafb2bb61e..a9ac60d1be 100644 --- a/erpnext/hr/doctype/employee_checkin/employee_checkin.py +++ b/erpnext/hr/doctype/employee_checkin/employee_checkin.py @@ -7,6 +7,7 @@ from frappe import _ from frappe.model.document import Document from frappe.utils import cint, get_datetime +from erpnext.hr.doctype.attendance.attendance import get_duplicate_attendance_record from erpnext.hr.doctype.shift_assignment.shift_assignment import ( get_actual_start_end_datetime_of_shift, ) @@ -124,12 +125,9 @@ def mark_attendance_and_link_log( ("1", log_names), ) 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, "docstatus": ("!=", "2")}, - ): + elif attendance_status in ('Present', 'Absent', 'Half Day'): + employee_doc = frappe.get_doc('Employee', employee) + if not get_duplicate_attendance_record(employee, attendance_date, shift): doc_dict = { "doctype": "Attendance", "employee": employee, diff --git a/erpnext/hr/doctype/shift_type/shift_type.py b/erpnext/hr/doctype/shift_type/shift_type.py index e5a5565a2b..17bca601d4 100644 --- a/erpnext/hr/doctype/shift_type/shift_type.py +++ b/erpnext/hr/doctype/shift_type/shift_type.py @@ -30,48 +30,31 @@ class ShiftType(Document): or not self.last_sync_of_checkin ): return + filters = { - "skip_auto_attendance": "0", - "attendance": ("is", "not set"), - "time": (">=", self.process_attendance_after), - "shift_actual_end": ("<", self.last_sync_of_checkin), - "shift": self.name, + 'skip_auto_attendance': 0, + 'attendance': ('is', 'not set'), + 'time': ('>=', self.process_attendance_after), + 'shift_actual_end': ('<', self.last_sync_of_checkin), + 'shift': self.name } - logs = frappe.db.get_list( - "Employee Checkin", fields="*", filters=filters, order_by="employee,time" - ) - for key, group in itertools.groupby( - logs, key=lambda x: (x["employee"], x["shift_actual_start"]) - ): + logs = frappe.db.get_list('Employee Checkin', 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, - late_entry, - early_exit, - in_time, - out_time, - ) = self.get_attendance(single_shift_logs) - mark_attendance_and_link_log( - single_shift_logs, - attendance_status, - key[1].date(), - working_hours, - late_entry, - early_exit, - in_time, - out_time, - self.name, - ) + attendance_status, working_hours, late_entry, early_exit, in_time, out_time = self.get_attendance(single_shift_logs) + mark_attendance_and_link_log(single_shift_logs, attendance_status, key[1].date(), + working_hours, late_entry, early_exit, in_time, out_time, 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, late_entry, early_exit, in_time, out_time 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 + Assumption: + 1. These logs belongs to a single shift, single employee and it's not in a holiday date. + 2. Logs are in chronological order """ late_entry = early_exit = False total_working_hours, in_time, out_time = calculate_working_hours( @@ -115,7 +98,7 @@ class ShiftType(Document): start_date = max(getdate(self.process_attendance_after), date_of_joining) actual_shift_datetime = get_actual_start_end_datetime_of_shift(employee, get_datetime(self.last_sync_of_checkin), True) last_shift_time = actual_shift_datetime.actual_start if actual_shift_datetime else get_datetime(self.last_sync_of_checkin) - prev_shift = get_employee_shift(employee, last_shift_time.date()-timedelta(days=1), True, 'reverse') + prev_shift = get_employee_shift(employee, last_shift_time - timedelta(days=1), True, 'reverse') if prev_shift: end_date = ( min(prev_shift.start_datetime.date(), relieving_date) @@ -128,8 +111,9 @@ class ShiftType(Document): if not holiday_list_name: holiday_list_name = get_holiday_list_for_employee(employee, False) dates = get_filtered_date_list(employee, start_date, end_date, holiday_list=holiday_list_name) + for date in dates: - shift_details = get_employee_shift(employee, date, True) + shift_details = get_employee_shift(employee, get_datetime(date), True) if shift_details and shift_details.shift_type.name == self.name: mark_attendance(employee, date, "Absent", self.name) From 4ef29119534c796d70cabef0bf314d87bca16757 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Wed, 23 Feb 2022 09:50:11 +0530 Subject: [PATCH 09/31] refactor: mark absent for employees with no attendance - break down into smaller functions - make it work with multiple shifts - this will mark employee as absent per shift, meaning employee can be present for one shift and absent for another on the same day --- erpnext/hr/doctype/shift_type/shift_type.py | 119 ++++++++------------ 1 file changed, 48 insertions(+), 71 deletions(-) diff --git a/erpnext/hr/doctype/shift_type/shift_type.py b/erpnext/hr/doctype/shift_type/shift_type.py index 17bca601d4..27d368ca0a 100644 --- a/erpnext/hr/doctype/shift_type/shift_type.py +++ b/erpnext/hr/doctype/shift_type/shift_type.py @@ -3,21 +3,23 @@ import itertools -from datetime import timedelta +from datetime import datetime, timedelta import frappe from frappe.model.document import Document -from frappe.utils import cint, get_datetime, getdate +from frappe.utils import cint, get_datetime, get_time, getdate +from erpnext.buying.doctype.supplier_scorecard.supplier_scorecard import daterange from erpnext.hr.doctype.attendance.attendance import mark_attendance from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee from erpnext.hr.doctype.employee_checkin.employee_checkin import ( calculate_working_hours, mark_attendance_and_link_log, ) +from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday from erpnext.hr.doctype.shift_assignment.shift_assignment import ( - get_actual_start_end_datetime_of_shift, get_employee_shift, + get_shift_details ) @@ -90,46 +92,60 @@ class ShiftType(Document): """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(getdate(self.process_attendance_after), date_of_joining) - actual_shift_datetime = get_actual_start_end_datetime_of_shift(employee, get_datetime(self.last_sync_of_checkin), True) - last_shift_time = actual_shift_datetime.actual_start if actual_shift_datetime else get_datetime(self.last_sync_of_checkin) - prev_shift = get_employee_shift(employee, last_shift_time - 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: + start_date, end_date = self.get_start_and_end_dates(employee) + + # no shift assignment found, no need to process absent attendance records + if end_date is None: return + holiday_list_name = self.holiday_list if not holiday_list_name: holiday_list_name = get_holiday_list_for_employee(employee, False) - dates = get_filtered_date_list(employee, start_date, end_date, holiday_list=holiday_list_name) - for date in dates: - shift_details = get_employee_shift(employee, get_datetime(date), True) + start_time = get_time(self.start_time) + + for date in daterange(getdate(start_date), getdate(end_date)): + if is_holiday(holiday_list_name, date): + # skip marking absent on a holiday + continue + + timestamp = datetime.combine(date, start_time) + shift_details = get_employee_shift(employee, timestamp, True) + if shift_details and shift_details.shift_type.name == self.name: mark_attendance(employee, date, "Absent", self.name) - def get_assigned_employee(self, from_date=None, consider_default_shift=False): - filters = {"start_date": (">", from_date), "shift_type": self.name, "docstatus": "1"} - if not from_date: - del filters["start_date"] + def get_start_and_end_dates(self, employee): + date_of_joining, relieving_date, employee_creation = frappe.db.get_value("Employee", employee, + ["date_of_joining", "relieving_date", "creation"]) - assigned_employees = frappe.get_all("Shift Assignment", "employee", filters, as_list=True) - assigned_employees = [x[0] for x in assigned_employees] + if not date_of_joining: + date_of_joining = employee_creation.date() + + start_date = max(getdate(self.process_attendance_after), date_of_joining) + end_date = None + + shift_details = get_shift_details(self.name, get_datetime(self.last_sync_of_checkin)) + last_shift_time = shift_details.actual_start if shift_details else get_datetime(self.last_sync_of_checkin) + + prev_shift = get_employee_shift(employee, last_shift_time - 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() + + return start_date, end_date + + def get_assigned_employee(self, from_date=None, consider_default_shift=False): + filters = {'shift_type': self.name, 'docstatus': '1'} + if from_date: + filters['start_date'] = ('>', from_date) + + assigned_employees = frappe.get_all('Shift Assignment', filters=filters, pluck='employee') if consider_default_shift: - filters = {"default_shift": self.name, "status": ["!=", "Inactive"]} - 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)) + filters = {'default_shift': self.name, 'status': ['!=', 'Inactive']} + default_shift_employees = frappe.get_all('Employee', filters=filters, pluck='name') + + return list(set(assigned_employees+default_shift_employees)) return assigned_employees @@ -138,42 +154,3 @@ def process_auto_attendance_for_all_shifts(): 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] From f2ee36579b10cb9f913cebf5705e8be19f1410a7 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Wed, 23 Feb 2022 11:22:58 +0530 Subject: [PATCH 10/31] chore: sort imports, remove unused imports --- erpnext/hr/doctype/attendance/attendance.py | 2 +- erpnext/hr/doctype/shift_assignment/shift_assignment.py | 9 ++++----- erpnext/hr/doctype/shift_type/shift_type.py | 2 +- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/erpnext/hr/doctype/attendance/attendance.py b/erpnext/hr/doctype/attendance/attendance.py index 2d8bf15201..ae0f2e7c10 100644 --- a/erpnext/hr/doctype/attendance/attendance.py +++ b/erpnext/hr/doctype/attendance/attendance.py @@ -5,8 +5,8 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import cint, cstr, formatdate, get_datetime, get_link_to_form, getdate, nowdate from frappe.query_builder import Criterion +from frappe.utils import cint, cstr, formatdate, get_datetime, get_link_to_form, getdate, nowdate from erpnext.hr.utils import get_holiday_dates_for_employee, validate_active_employee diff --git a/erpnext/hr/doctype/shift_assignment/shift_assignment.py b/erpnext/hr/doctype/shift_assignment/shift_assignment.py index 702d3a2506..768a86258e 100644 --- a/erpnext/hr/doctype/shift_assignment/shift_assignment.py +++ b/erpnext/hr/doctype/shift_assignment/shift_assignment.py @@ -3,19 +3,18 @@ from datetime import datetime, timedelta +from typing import Dict, List import frappe from frappe import _ from frappe.model.document import Document -from frappe.query_builder import Criterion, Column -from frappe.utils import cstr, get_link_to_form, get_datetime, get_time, getdate, now_datetime, nowdate +from frappe.query_builder import Criterion +from frappe.utils import cstr, get_datetime, get_link_to_form, get_time, getdate, now_datetime from erpnext.hr.doctype.employee.employee import get_holiday_list_for_employee from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday from erpnext.hr.utils import validate_active_employee -from typing import Dict, List - class OverlappingShiftError(frappe.ValidationError): pass @@ -374,7 +373,7 @@ def get_exact_shift(shifts: List, for_timestamp: datetime) -> Dict: timestamp_index = index elif for_timestamp == timestamp: # on timestamp boundary - if index%2 == 1: + if index % 2 == 1: timestamp_index = index else: timestamp_index = index + 1 diff --git a/erpnext/hr/doctype/shift_type/shift_type.py b/erpnext/hr/doctype/shift_type/shift_type.py index 27d368ca0a..dd1dff1bc4 100644 --- a/erpnext/hr/doctype/shift_type/shift_type.py +++ b/erpnext/hr/doctype/shift_type/shift_type.py @@ -19,7 +19,7 @@ from erpnext.hr.doctype.employee_checkin.employee_checkin import ( from erpnext.hr.doctype.holiday_list.holiday_list import is_holiday from erpnext.hr.doctype.shift_assignment.shift_assignment import ( get_employee_shift, - get_shift_details + get_shift_details, ) From e79d292233000985a04c5d46859513c1e0d7c88c Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 27 Mar 2022 23:46:03 +0530 Subject: [PATCH 11/31] refactor: Monthly Attendance Sheet - split into smaller functions - add type hints - get rid of unnecessary db calls and loops - add docstrings for functions --- .../monthly_attendance_sheet.js | 3 +- .../monthly_attendance_sheet.py | 749 ++++++++++-------- .../test_monthly_attendance_sheet.py | 13 +- 3 files changed, 448 insertions(+), 317 deletions(-) diff --git a/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.js b/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.js index 42f7cdb50f..26c868498b 100644 --- a/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.js +++ b/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.js @@ -66,8 +66,7 @@ frappe.query_reports["Monthly Attendance Sheet"] = { "Default": 0, } ], - - "onload": function() { + onload: function() { return frappe.call({ method: "erpnext.hr.report.monthly_attendance_sheet.monthly_attendance_sheet.get_attendance_years", callback: function(r) { diff --git a/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py b/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py index 8ea49899f2..c9d9aae361 100644 --- a/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py +++ b/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py @@ -3,365 +3,496 @@ from calendar import monthrange +from itertools import groupby +from typing import Dict, List, Optional, Tuple import frappe from frappe import _, msgprint from frappe.utils import cint, cstr, getdate +from frappe.query_builder.functions import Count, Extract, Sum + +Filters = frappe._dict + status_map = { - "Absent": "A", - "Half Day": "HD", - "Holiday": "H", - "Weekly Off": "WO", - "On Leave": "L", - "Present": "P", - "Work From Home": "WFH", + 'Absent': 'A', + 'Half Day': 'HD', + 'Holiday': 'H', + 'Weekly Off': 'WO', + 'On Leave': 'L', + 'Present': 'P', + 'Work From Home': 'WFH' } -day_abbr = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] +day_abbr = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] +def execute(filters: Optional[Filters] = None) -> Tuple: + filters = frappe._dict(filters or {}) -def execute(filters=None): - if not filters: - filters = {} + if not (filters.month and filters.year): + frappe.throw(_('Please select month and year.')) - if filters.hide_year_field == 1: - filters.year = 2020 + columns = get_columns(filters) + data, attendance_map = get_data(filters) - conditions, filters = get_conditions(filters) - columns, days = get_columns(filters) - att_map = get_attendance_list(conditions, filters) - if not att_map: + if not data: return columns, [], None, None - if filters.group_by: - emp_map, group_by_parameters = get_employee_details(filters.group_by, filters.company) - holiday_list = [] - for parameter in group_by_parameters: - h_list = [ - emp_map[parameter][d]["holiday_list"] - for d in emp_map[parameter] - if emp_map[parameter][d]["holiday_list"] - ] - holiday_list += h_list - else: - emp_map = get_employee_details(filters.group_by, filters.company) - holiday_list = [emp_map[d]["holiday_list"] for d in emp_map if emp_map[d]["holiday_list"]] + chart = get_chart_data(attendance_map, filters) - default_holiday_list = frappe.get_cached_value( - "Company", filters.get("company"), "default_holiday_list" - ) - holiday_list.append(default_holiday_list) - holiday_list = list(set(holiday_list)) - holiday_map = get_holiday(holiday_list, filters["month"]) - - data = [] - - leave_types = frappe.db.get_list("Leave Type") - leave_list = None - if filters.summarized_view: - leave_list = [d.name + ":Float:120" for d in leave_types] - columns.extend(leave_list) - columns.extend([_("Total Late Entries") + ":Float:120", _("Total Early Exits") + ":Float:120"]) - - if filters.group_by: - emp_att_map = {} - for parameter in group_by_parameters: - emp_map_set = set([key for key in emp_map[parameter].keys()]) - att_map_set = set([key for key in att_map.keys()]) - if att_map_set & emp_map_set: - parameter_row = ["" + parameter + ""] + [ - "" for day in range(filters["total_days_in_month"] + 2) - ] - data.append(parameter_row) - record, emp_att_data = add_data( - emp_map[parameter], - att_map, - filters, - holiday_map, - conditions, - default_holiday_list, - leave_types=leave_types, - ) - emp_att_map.update(emp_att_data) - data += record - else: - record, emp_att_map = add_data( - emp_map, - att_map, - filters, - holiday_map, - conditions, - default_holiday_list, - leave_types=leave_types, - ) - data += record - - chart_data = get_chart_data(emp_att_map, days) - - return columns, data, None, chart_data + return columns, data, None, chart -def get_chart_data(emp_att_map, days): - labels = [] - datasets = [ - {"name": "Absent", "values": []}, - {"name": "Present", "values": []}, - {"name": "Leave", "values": []}, - ] - for idx, day in enumerate(days, start=0): - p = day.replace("::65", "") - labels.append(day.replace("::65", "")) - total_absent_on_day = 0 - total_leave_on_day = 0 - total_present_on_day = 0 - total_holiday = 0 - for emp in emp_att_map.keys(): - if emp_att_map[emp][idx]: - if emp_att_map[emp][idx] == "A": - total_absent_on_day += 1 - if emp_att_map[emp][idx] in ["P", "WFH"]: - total_present_on_day += 1 - if emp_att_map[emp][idx] == "HD": - total_present_on_day += 0.5 - total_leave_on_day += 0.5 - if emp_att_map[emp][idx] == "L": - total_leave_on_day += 1 - - datasets[0]["values"].append(total_absent_on_day) - datasets[1]["values"].append(total_present_on_day) - datasets[2]["values"].append(total_leave_on_day) - - chart = {"data": {"labels": labels, "datasets": datasets}} - - chart["type"] = "line" - - return chart - - -def add_data( - employee_map, att_map, filters, holiday_map, conditions, default_holiday_list, leave_types=None -): - - record = [] - emp_att_map = {} - for emp in employee_map: - emp_det = employee_map.get(emp) - if not emp_det or emp not in att_map: - continue - - row = [] - if filters.group_by: - row += [" "] - row += [emp, emp_det.employee_name] - - total_p = total_a = total_l = total_h = total_um = 0.0 - emp_status_map = [] - for day in range(filters["total_days_in_month"]): - status = None - status = att_map.get(emp).get(day + 1) - - if status is None and holiday_map: - emp_holiday_list = emp_det.holiday_list if emp_det.holiday_list else default_holiday_list - - if emp_holiday_list in holiday_map: - for idx, ele in enumerate(holiday_map[emp_holiday_list]): - if day + 1 == holiday_map[emp_holiday_list][idx][0]: - if holiday_map[emp_holiday_list][idx][1]: - status = "Weekly Off" - else: - status = "Holiday" - total_h += 1 - - abbr = status_map.get(status, "") - emp_status_map.append(abbr) - - if filters.summarized_view: - if status == "Present" or status == "Work From Home": - total_p += 1 - elif status == "Absent": - total_a += 1 - elif status == "On Leave": - total_l += 1 - elif status == "Half Day": - total_p += 0.5 - total_a += 0.5 - total_l += 0.5 - elif not status: - total_um += 1 - - if not filters.summarized_view: - row += emp_status_map - - if filters.summarized_view: - row += [total_p, total_l, total_a, total_h, total_um] - - if not filters.get("employee"): - filters.update({"employee": emp}) - conditions += " and employee = %(employee)s" - elif not filters.get("employee") == emp: - filters.update({"employee": emp}) - - if filters.summarized_view: - leave_details = frappe.db.sql( - """select leave_type, status, count(*) as count from `tabAttendance`\ - where leave_type is not NULL %s group by leave_type, status""" - % conditions, - filters, - as_dict=1, - ) - - time_default_counts = frappe.db.sql( - """select (select count(*) from `tabAttendance` where \ - late_entry = 1 %s) as late_entry_count, (select count(*) from tabAttendance where \ - early_exit = 1 %s) as early_exit_count""" - % (conditions, conditions), - filters, - ) - - leaves = {} - for d in leave_details: - if d.status == "Half Day": - d.count = d.count * 0.5 - if d.leave_type in leaves: - leaves[d.leave_type] += d.count - else: - leaves[d.leave_type] = d.count - - for d in leave_types: - if d.name in leaves: - row.append(leaves[d.name]) - else: - row.append("0.0") - - row.extend([time_default_counts[0][0], time_default_counts[0][1]]) - emp_att_map[emp] = emp_status_map - record.append(row) - - return record, emp_att_map - - -def get_columns(filters): - +def get_columns(filters: Filters) -> List[Dict]: columns = [] if filters.group_by: - columns = [_(filters.group_by) + ":Link/Branch:120"] + columns.append( + {'label': _(filters.group_by), 'fieldname': frappe.scrub(filters.group_by), 'fieldtype': 'Link', 'options': 'Branch', 'width': 120} + ) - columns += [_("Employee") + ":Link/Employee:120", _("Employee Name") + ":Data/:120"] - days = [] - for day in range(filters["total_days_in_month"]): - date = str(filters.year) + "-" + str(filters.month) + "-" + str(day + 1) - day_name = day_abbr[getdate(date).weekday()] - days.append(cstr(day + 1) + " " + day_name + "::65") - if not filters.summarized_view: - columns += days + columns.extend([ + {'label': _('Employee'), 'fieldname': 'employee', 'fieldtype': 'Link', 'options': 'Employee', 'width': 120}, + {'label': _('Employee Name'), 'fieldname': 'employee_name', 'fieldtype': 'Data', 'width': 120} + ]) if filters.summarized_view: - columns += [ - _("Total Present") + ":Float:120", - _("Total Leaves") + ":Float:120", - _("Total Absent") + ":Float:120", - _("Total Holidays") + ":Float:120", - _("Unmarked Days") + ":Float:120", - ] - return columns, days + columns.extend([ + {'label': _('Total Present'), 'fieldname': 'total_present', 'fieldtype': 'Float', 'width': 120}, + {'label': _('Total Leaves'), 'fieldname': 'total_leaves', 'fieldtype': 'Float', 'width': 120}, + {'label': _('Total Absent'), 'fieldname': 'total_absent', 'fieldtype': 'Float', 'width': 120}, + {'label': _('Total Holidays'), 'fieldname': 'total_holidays', 'fieldtype': 'Float', 'width': 120}, + {'label': _('Unmarked Days'), 'fieldname': 'unmarked_days', 'fieldtype': 'Float', 'width': 120} + ]) + columns.extend(get_columns_for_leave_types()) + columns.extend([ + {'label': _('Total Late Entries'), 'fieldname': 'total_late_entries', 'fieldtype': 'Float', 'width': 120}, + {'label': _('Total Early Exits'), 'fieldname': 'total_early_exits', 'fieldtype': 'Float', 'width': 120} + ]) + else: + columns.extend(get_columns_for_days(filters)) + + return columns -def get_attendance_list(conditions, filters): - attendance_list = frappe.db.sql( - """select employee, day(attendance_date) as day_of_month, - status from tabAttendance where docstatus = 1 %s order by employee, attendance_date""" - % conditions, - filters, - as_dict=1, +def get_columns_for_leave_types() -> List[Dict]: + leave_types = frappe.db.get_all('Leave Type', pluck='name') + types = [] + for entry in leave_types: + types.append({'label': entry, 'fieldname': frappe.scrub(entry), 'fieldtype': 'Float', 'width': 120}) + + return types + + +def get_columns_for_days(filters: Filters) -> List[Dict]: + total_days = get_total_days_in_month(filters) + days = [] + + for day in range(1, total_days+1): + # forms the dates from selected year and month from filters + date = '{}-{}-{}'.format( + cstr(filters.year), + cstr(filters.month), + cstr(day) + ) + # gets abbr from weekday number + weekday = day_abbr[getdate(date).weekday()] + # sets days as 1 Mon, 2 Tue, 3 Wed + label = '{} {}'.format(cstr(day), weekday) + days.append({ + 'label': label, + 'fieldtype': 'Data', + 'fieldname': day, + 'width': 65 + }) + + return days + + +def get_total_days_in_month(filters: Filters) -> int: + return monthrange(cint(filters.year), cint(filters.month))[1] + + +def get_data(filters: Filters) -> Tuple[List, Dict]: + attendance_map = get_attendance_map(filters) + + if not attendance_map: + frappe.msgprint(_('No attendance records found.'), alert=True, indicator='orange') + return [], {} + + employee_details, group_by_param_values = get_employee_related_details(filters.group_by, filters.company) + holiday_map = get_holiday_map(filters) + + data = [] + + if filters.group_by: + group_by_column = frappe.scrub(filters.group_by) + + for value in group_by_param_values: + if not value: + continue + + records = get_rows( + employee_details[value], + attendance_map, + filters, + holiday_map + ) + + if records: + data.append({ + group_by_column: frappe.bold(value) + }) + data.extend(records) + else: + data = get_rows( + employee_details, + attendance_map, + filters, + holiday_map + ) + + if not data: + frappe.msgprint(_('No attendance records found for this criteria.'), alert=True, indicator='orange') + return [], {} + + return data, attendance_map + + +def get_attendance_map(filters: Filters) -> Dict[str, Dict[int, str]]: + """Returns a dictionary of employee wise attendance map for all the days of the month like + { + 'employee1': { + 1: 'Present', + 2: 'Absent' + }, + 'employee2': { + 1: 'Absent', + 2: 'Present' + } + } + """ + Attendance = frappe.qb.DocType('Attendance') + query = ( + frappe.qb.from_(Attendance) + .select( + Attendance.employee, + Extract('day', Attendance.attendance_date).as_('day_of_month'), + Attendance.status + ).where( + (Attendance.docstatus == 1) + & (Attendance.company == filters.company) + & (Extract('month', Attendance.attendance_date) == filters.month) + & (Extract('year', Attendance.attendance_date) == filters.year) + ) ) + if filters.employee: + query = query.where(Attendance.employee == filters.employee) + + query = query.orderby(Attendance.employee, Attendance.attendance_date) + + attendance_list = query.run(as_dict=1) if not attendance_list: - msgprint(_("No attendance record found"), alert=True, indicator="orange") + frappe.msgprint(_('No attendance records found'), alert=True, indicator='orange') - att_map = {} + attendance_map = {} for d in attendance_list: - att_map.setdefault(d.employee, frappe._dict()).setdefault(d.day_of_month, "") - att_map[d.employee][d.day_of_month] = d.status + attendance_map.setdefault(d.employee, frappe._dict()).setdefault(d.day_of_month, '') + attendance_map[d.employee][d.day_of_month] = d.status - return att_map + return attendance_map -def get_conditions(filters): - if not (filters.get("month") and filters.get("year")): - msgprint(_("Please select month and year"), raise_exception=1) - - filters["total_days_in_month"] = monthrange(cint(filters.year), cint(filters.month))[1] - - conditions = " and month(attendance_date) = %(month)s and year(attendance_date) = %(year)s" - - if filters.get("company"): - conditions += " and company = %(company)s" - if filters.get("employee"): - conditions += " and employee = %(employee)s" - - return conditions, filters - - -def get_employee_details(group_by, company): - emp_map = {} - query = """select name, employee_name, designation, department, branch, company, - holiday_list from `tabEmployee` where company = %s """ % frappe.db.escape( - company +def get_employee_related_details(group_by: str, company: str) -> Tuple[Dict, List]: + """Returns + 1. nested dict for employee details + 2. list of values for the group by filter + eg: if group by filter is set to "Department" then returns a list like ['HR', 'Support', 'Engineering'] + """ + Employee = frappe.qb.DocType('Employee') + query = ( + frappe.qb.from_(Employee) + .select( + Employee.name, Employee.employee_name, Employee.designation, + Employee.grade, Employee.department, Employee.branch, + Employee.company, Employee.holiday_list + ).where(Employee.company == company) ) if group_by: group_by = group_by.lower() - query += " order by " + group_by + " ASC" + query = query.orderby(group_by) - employee_details = frappe.db.sql(query, as_dict=1) + employee_details = query.run(as_dict=True) + + group_by_param_values = [] + emp_map = {} - group_by_parameters = [] if group_by: + for parameter, employees in groupby(employee_details, key=lambda d: d[group_by]): + group_by_param_values.append(parameter) + emp_map.setdefault(parameter, frappe._dict()) - group_by_parameters = list( - set(detail.get(group_by, "") for detail in employee_details if detail.get(group_by, "")) - ) - for parameter in group_by_parameters: - emp_map[parameter] = {} - - for d in employee_details: - if group_by and len(group_by_parameters): - if d.get(group_by, None): - - emp_map[d.get(group_by)][d.name] = d - else: - emp_map[d.name] = d - - if not group_by: - return emp_map + for emp in employees: + emp_map[parameter][emp.name] = emp else: - return emp_map, group_by_parameters + for emp in employee_details: + emp_map[emp.name] = emp + + return emp_map, group_by_param_values -def get_holiday(holiday_list, month): +def get_holiday_map(filters: Filters) -> Dict[str, List[Dict]]: + """ + Returns a dict of holidays falling in the filter month and year + with list name as key and list of holidays as values like + { + 'Holiday List 1': [ + {'day_of_month': '0' , 'weekly_off': 1}, + {'day_of_month': '1', 'weekly_off': 0} + ], + 'Holiday List 2': [ + {'day_of_month': '0' , 'weekly_off': 1}, + {'day_of_month': '1', 'weekly_off': 0} + ] + } + """ + # add default holiday list too + holiday_lists = frappe.db.get_all('Holiday List', pluck='name') + default_holiday_list = frappe.get_cached_value('Company', filters.company, 'default_holiday_list') + holiday_lists.append(default_holiday_list) + holiday_map = frappe._dict() - for d in holiday_list: - if d: - holiday_map.setdefault( - d, - frappe.db.sql( - """select day(holiday_date), weekly_off from `tabHoliday` - where parent=%s and month(holiday_date)=%s""", - (d, month), - ), + Holiday = frappe.qb.DocType('Holiday') + + for d in holiday_lists: + if not d: + continue + + holidays = ( + frappe.qb.from_(Holiday) + .select( + Extract('day', Holiday.holiday_date).as_('day_of_month'), + Holiday.weekly_off + ).where( + (Holiday.parent == d) + & (Extract('month', Holiday.holiday_date) == filters.month) + & (Extract('year', Holiday.holiday_date) == filters.year) ) + ).run(as_dict=True) + + holiday_map.setdefault(d, holidays) return holiday_map +def get_rows(employee_details: Dict, attendance_map: Dict, filters: Filters, holiday_map: Dict) -> List[Dict]: + records = [] + default_holiday_list = frappe.get_cached_value('Company', filters.company, 'default_holiday_list') + employees_with_attendance = list(attendance_map.keys()) + + for employee, details in employee_details.items(): + if employee not in employees_with_attendance: + continue + + row = { + 'employee': employee, + 'employee_name': details.employee_name + } + + if filters.summarized_view: + # set defaults for summarized view + for entry in get_columns(filters): + if entry.get('fieldtype') == 'Float': + row[entry.get('fieldname')] = 0.0 + + emp_holiday_list = details.holiday_list or default_holiday_list + holidays = holiday_map[emp_holiday_list] + + attendance_for_employee = get_attendance_status_for_employee(employee, filters, attendance_map, holidays) + row.update(attendance_for_employee) + + if filters.summarized_view: + leave_summary = get_leave_summary(employee, filters) + entry_exits_summary = get_entry_exits_summary(employee, filters) + + row.update(leave_summary) + row.update(entry_exits_summary) + + records.append(row) + + return records + + +def get_attendance_status_for_employee(employee: str, filters: Filters, attendance_map: Dict, holidays: List) -> Dict: + """Returns dict of attendance status for employee + - for summarized view: {'total_present': 1.5, 'total_leaves': 0.5, 'total_absent': 13.5, 'total_holidays': 8, 'unmarked_days': 5} + - for detailed view (day wise): {1: 'A', 2: 'P', 3: 'A'....} + """ + emp_attendance = {} + + total_days = get_total_days_in_month(filters) + totals = {'total_present': 0, 'total_leaves': 0, 'total_absent': 0, 'total_holidays': 0, 'unmarked_days': 0} + + for day in range(1, total_days + 1): + status = None + employee_attendance = attendance_map.get(employee) + if employee_attendance: + status = employee_attendance.get(day) + + if status is None and holidays: + status = get_holiday_status(day, holidays) + + if filters.summarized_view: + if status in ['Present', 'Work From Home']: + totals['total_present'] += 1 + elif status in ['Weekly Off', 'Holiday']: + totals['total_holidays'] += 1 + elif status == 'Absent': + totals['total_absent'] += 1 + elif status == 'On Leave': + totals['total_leaves'] += 1 + elif status == 'Half Day': + totals['total_present'] += 0.5 + totals['total_absent'] += 0.5 + totals['total_leaves'] += 0.5 + elif not status: + totals['unmarked_days'] += 1 + else: + abbr = status_map.get(status, '') + emp_attendance[day] = abbr + + if filters.summarized_view: + emp_attendance.update(totals) + + return emp_attendance + + +def get_holiday_status(day: int, holidays: List) -> str: + status = None + for holiday in holidays: + if day == holiday.get('day_of_month'): + if holiday.get('weekly_off'): + status = 'Weekly Off' + else: + status = 'Holiday' + break + return status + + +def get_leave_summary(employee: str, filters: Filters) -> Dict[str, float]: + """Returns a dict of leave type and corresponding leaves taken by employee like: + {'leave_without_pay': 1.0, 'sick_leave': 2.0} + """ + Attendance = frappe.qb.DocType('Attendance') + day_case = frappe.qb.terms.Case().when(Attendance.status == 'Half Day', 0.5).else_(1) + sum_leave_days = Sum(day_case).as_('leave_days') + + leave_details = ( + frappe.qb.from_(Attendance) + .select(Attendance.leave_type, sum_leave_days) + .where( + (Attendance.employee == employee) + & (Attendance.company == filters.company) + & ((Attendance.leave_type.isnotnull()) | (Attendance.leave_type != '')) + & (Extract('month', Attendance.attendance_date) == filters.month) + & (Extract('year', Attendance.attendance_date) == filters.year) + ).groupby(Attendance.leave_type) + ).run(as_dict=True) + + leaves = {} + for d in leave_details: + leave_type = frappe.scrub(d.leave_type) + leaves[leave_type] = d.leave_days + + return leaves + + +def get_entry_exits_summary(employee: str, filters: Filters) -> Dict[str, float]: + """Returns total late entries and total early exits for employee like: + {'total_late_entries': 5, 'total_early_exits': 2} + """ + Attendance = frappe.qb.DocType('Attendance') + + late_entry_case = frappe.qb.terms.Case().when(Attendance.late_entry == '1', '1') + count_late_entries = Count(late_entry_case).as_('total_late_entries') + + early_exit_case = frappe.qb.terms.Case().when(Attendance.early_exit == '1', '1') + count_early_exits = Count(early_exit_case).as_('total_early_exits') + + entry_exits = ( + frappe.qb.from_(Attendance) + .select(count_late_entries, count_early_exits) + .where( + (Attendance.employee == employee) + & (Attendance.company == filters.company) + & (Extract('month', Attendance.attendance_date) == filters.month) + & (Extract('year', Attendance.attendance_date) == filters.year) + ) + ).run(as_dict=True) + + return entry_exits[0] + + @frappe.whitelist() -def get_attendance_years(): - year_list = frappe.db.sql_list( - """select distinct YEAR(attendance_date) from tabAttendance ORDER BY YEAR(attendance_date) DESC""" - ) - if not year_list: +def get_attendance_years() -> str: + """Returns all the years for which attendance records exist""" + Attendance = frappe.qb.DocType('Attendance') + year_list = ( + frappe.qb.from_(Attendance) + .select(Extract('year', Attendance.attendance_date).as_('year')) + .distinct() + ).run(as_dict=True) + + if year_list: + year_list.sort(key=lambda d: d.year, reverse=True) + else: year_list = [getdate().year] - return "\n".join(str(year) for year in year_list) + return "\n".join(cstr(entry.year) for entry in year_list) + + +def get_chart_data(attendance_map: Dict, filters: Filters) -> Dict: + days = get_columns_for_days(filters) + labels = [] + absent = [] + present = [] + leave = [] + + for day in days: + labels.append(day['label']) + total_absent_on_day = total_leaves_on_day = total_present_on_day = 0 + + for employee, attendance in attendance_map.items(): + attendance_on_day = attendance.get(day['fieldname']) + if not attendance_on_day: + continue + + if attendance_on_day == 'Absent': + total_absent_on_day += 1 + elif attendance_on_day in ['Present', 'Work From Home']: + total_present_on_day += 1 + elif attendance_on_day == 'Half Day': + total_present_on_day += 0.5 + total_leaves_on_day += 0.5 + elif attendance_on_day == 'On Leave': + total_leaves_on_day += 1 + + absent.append(total_absent_on_day) + present.append(total_present_on_day) + leave.append(total_leaves_on_day) + + return { + 'data': { + 'labels': labels, + 'datasets': [ + {'name': 'Absent', 'values': absent}, + {'name': 'Present', 'values': present}, + {'name': 'Leave', 'values': leave}, + ] + }, + 'type': 'line' + } + + return chart \ No newline at end of file diff --git a/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py b/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py index 91da08eee5..9f2babb227 100644 --- a/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py +++ b/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py @@ -33,14 +33,15 @@ class TestMonthlyAttendanceSheet(FrappeTestCase): } ) report = execute(filters=filters) - employees = report[1][0] - datasets = report[3]["data"]["datasets"] - absent = datasets[0]["values"] - present = datasets[1]["values"] - leaves = datasets[2]["values"] + + record = report[1][0] + datasets = report[3]['data']['datasets'] + absent = datasets[0]['values'] + present = datasets[1]['values'] + leaves = datasets[2]['values'] # ensure correct attendance is reflect on the report - self.assertIn(self.employee, employees) + self.assertEqual(self.employee, record.get('employee')) self.assertEqual(absent[0], 1) self.assertEqual(present[1], 1) self.assertEqual(leaves[2], 1) From 865204a541651c284979a824576cdfcc4d789056 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 28 Mar 2022 01:07:18 +0530 Subject: [PATCH 12/31] feat: add colors for attendance status to lessen the cognitive load - legend with colors and full form for status abbreviations --- .../monthly_attendance_sheet.js | 20 +++++++++++++ .../monthly_attendance_sheet.py | 30 +++++++++++++++---- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.js b/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.js index 26c868498b..6f4bbd54fb 100644 --- a/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.js +++ b/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.js @@ -77,5 +77,25 @@ frappe.query_reports["Monthly Attendance Sheet"] = { year_filter.set_input(year_filter.df.default); } }); + }, + formatter: function(value, row, column, data, default_formatter) { + value = default_formatter(value, row, column, data); + const summarized_view = frappe.query_report.get_filter_value('summarized_view'); + const group_by = frappe.query_report.get_filter_value('group_by'); + + if (!summarized_view) { + if ((group_by && column.colIndex > 3) || (!group_by && column.colIndex > 2)) { + if (value == 'P' || value == 'WFH') + value = "" + value + ""; + else if (value == 'A') + value = "" + value + ""; + else if (value == 'HD') + value = "" + value + ""; + else if (value == 'L') + value = "" + value + ""; + } + } + + return value; } } diff --git a/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py b/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py index c9d9aae361..299b092eae 100644 --- a/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py +++ b/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py @@ -15,13 +15,13 @@ from frappe.query_builder.functions import Count, Extract, Sum Filters = frappe._dict status_map = { + 'Present': 'P', 'Absent': 'A', 'Half Day': 'HD', - 'Holiday': 'H', - 'Weekly Off': 'WO', + 'Work From Home': 'WFH', 'On Leave': 'L', - 'Present': 'P', - 'Work From Home': 'WFH' + 'Holiday': 'H', + 'Weekly Off': 'WO' } day_abbr = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] @@ -38,9 +38,26 @@ def execute(filters: Optional[Filters] = None) -> Tuple: if not data: return columns, [], None, None + message = get_message() if not filters.summarized_view else '' chart = get_chart_data(attendance_map, filters) - return columns, data, None, chart + return columns, data, message, chart + + +def get_message() -> str: + message = '' + colors = ['green', 'red', 'orange', 'green', '#318AD8', '', ''] + + count = 0 + for status, abbr in status_map.items(): + message += f""" + + {status} - {abbr} + + """ + count += 1 + + return message def get_columns(filters: Filters) -> List[Dict]: @@ -492,7 +509,8 @@ def get_chart_data(attendance_map: Dict, filters: Filters) -> Dict: {'name': 'Leave', 'values': leave}, ] }, - 'type': 'line' + 'type': 'line', + 'colors': ['red', 'green', 'blue'], } return chart \ No newline at end of file From 41cfcdba4422e8cdf69368fff81e471f048973c1 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 29 Mar 2022 12:36:58 +0530 Subject: [PATCH 13/31] feat: show shift-wise attendance in monthly attendance sheet --- .../monthly_attendance_sheet.py | 283 ++++++++++-------- 1 file changed, 164 insertions(+), 119 deletions(-) diff --git a/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py b/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py index 299b092eae..a98afe41cc 100644 --- a/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py +++ b/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py @@ -7,10 +7,9 @@ from itertools import groupby from typing import Dict, List, Optional, Tuple import frappe -from frappe import _, msgprint -from frappe.utils import cint, cstr, getdate - +from frappe import _ from frappe.query_builder.functions import Count, Extract, Sum +from frappe.utils import cint, cstr, getdate Filters = frappe._dict @@ -32,10 +31,16 @@ def execute(filters: Optional[Filters] = None) -> Tuple: if not (filters.month and filters.year): frappe.throw(_('Please select month and year.')) + attendance_map = get_attendance_map(filters) + if not attendance_map: + frappe.msgprint(_('No attendance records found.'), alert=True, indicator='orange') + return [], [], None, None + columns = get_columns(filters) - data, attendance_map = get_data(filters) + data = get_data(filters, attendance_map) if not data: + frappe.msgprint(_('No attendance records found for this criteria.'), alert=True, indicator='orange') return columns, [], None, None message = get_message() if not filters.summarized_view else '' @@ -69,24 +74,25 @@ def get_columns(filters: Filters) -> List[Dict]: ) columns.extend([ - {'label': _('Employee'), 'fieldname': 'employee', 'fieldtype': 'Link', 'options': 'Employee', 'width': 120}, + {'label': _('Employee'), 'fieldname': 'employee', 'fieldtype': 'Link', 'options': 'Employee', 'width': 135}, {'label': _('Employee Name'), 'fieldname': 'employee_name', 'fieldtype': 'Data', 'width': 120} ]) if filters.summarized_view: columns.extend([ - {'label': _('Total Present'), 'fieldname': 'total_present', 'fieldtype': 'Float', 'width': 120}, - {'label': _('Total Leaves'), 'fieldname': 'total_leaves', 'fieldtype': 'Float', 'width': 120}, - {'label': _('Total Absent'), 'fieldname': 'total_absent', 'fieldtype': 'Float', 'width': 120}, + {'label': _('Total Present'), 'fieldname': 'total_present', 'fieldtype': 'Float', 'width': 110}, + {'label': _('Total Leaves'), 'fieldname': 'total_leaves', 'fieldtype': 'Float', 'width': 110}, + {'label': _('Total Absent'), 'fieldname': 'total_absent', 'fieldtype': 'Float', 'width': 110}, {'label': _('Total Holidays'), 'fieldname': 'total_holidays', 'fieldtype': 'Float', 'width': 120}, - {'label': _('Unmarked Days'), 'fieldname': 'unmarked_days', 'fieldtype': 'Float', 'width': 120} + {'label': _('Unmarked Days'), 'fieldname': 'unmarked_days', 'fieldtype': 'Float', 'width': 130} ]) columns.extend(get_columns_for_leave_types()) columns.extend([ - {'label': _('Total Late Entries'), 'fieldname': 'total_late_entries', 'fieldtype': 'Float', 'width': 120}, - {'label': _('Total Early Exits'), 'fieldname': 'total_early_exits', 'fieldtype': 'Float', 'width': 120} + {'label': _('Total Late Entries'), 'fieldname': 'total_late_entries', 'fieldtype': 'Float', 'width': 140}, + {'label': _('Total Early Exits'), 'fieldname': 'total_early_exits', 'fieldtype': 'Float', 'width': 140} ]) else: + columns.append({'label': _('Shift'), 'fieldname': 'shift', 'fieldtype': 'Data', 'width': 120}) columns.extend(get_columns_for_days(filters)) return columns @@ -130,16 +136,9 @@ def get_total_days_in_month(filters: Filters) -> int: return monthrange(cint(filters.year), cint(filters.month))[1] -def get_data(filters: Filters) -> Tuple[List, Dict]: - attendance_map = get_attendance_map(filters) - - if not attendance_map: - frappe.msgprint(_('No attendance records found.'), alert=True, indicator='orange') - return [], {} - +def get_data(filters: Filters, attendance_map: Dict) -> List[Dict]: employee_details, group_by_param_values = get_employee_related_details(filters.group_by, filters.company) holiday_map = get_holiday_map(filters) - data = [] if filters.group_by: @@ -149,12 +148,7 @@ def get_data(filters: Filters) -> Tuple[List, Dict]: if not value: continue - records = get_rows( - employee_details[value], - attendance_map, - filters, - holiday_map - ) + records = get_rows(employee_details[value], filters, holiday_map, attendance_map) if records: data.append({ @@ -162,30 +156,21 @@ def get_data(filters: Filters) -> Tuple[List, Dict]: }) data.extend(records) else: - data = get_rows( - employee_details, - attendance_map, - filters, - holiday_map - ) + data = get_rows(employee_details, filters, holiday_map, attendance_map) - if not data: - frappe.msgprint(_('No attendance records found for this criteria.'), alert=True, indicator='orange') - return [], {} - - return data, attendance_map + return data -def get_attendance_map(filters: Filters) -> Dict[str, Dict[int, str]]: - """Returns a dictionary of employee wise attendance map for all the days of the month like +def get_attendance_map(filters: Filters) -> Dict: + """Returns a dictionary of employee wise attendance map as per shifts for all the days of the month like { 'employee1': { - 1: 'Present', - 2: 'Absent' + 'Morning Shift': {1: 'Present', 2: 'Absent', ...} + 'Evening Shift': {1: 'Absent', 2: 'Present', ...} }, 'employee2': { - 1: 'Absent', - 2: 'Present' + 'Afternoon Shift': {1: 'Present', 2: 'Absent', ...} + 'Night Shift': {1: 'Absent', 2: 'Absent', ...} } } """ @@ -195,7 +180,8 @@ def get_attendance_map(filters: Filters) -> Dict[str, Dict[int, str]]: .select( Attendance.employee, Extract('day', Attendance.attendance_date).as_('day_of_month'), - Attendance.status + Attendance.status, + Attendance.shift ).where( (Attendance.docstatus == 1) & (Attendance.company == filters.company) @@ -205,18 +191,14 @@ def get_attendance_map(filters: Filters) -> Dict[str, Dict[int, str]]: ) if filters.employee: query = query.where(Attendance.employee == filters.employee) - query = query.orderby(Attendance.employee, Attendance.attendance_date) attendance_list = query.run(as_dict=1) - - if not attendance_list: - frappe.msgprint(_('No attendance records found'), alert=True, indicator='orange') - attendance_map = {} + for d in attendance_list: - attendance_map.setdefault(d.employee, frappe._dict()).setdefault(d.day_of_month, '') - attendance_map[d.employee][d.day_of_month] = d.status + attendance_map.setdefault(d.employee, frappe._dict()).setdefault(d.shift, frappe._dict()) + attendance_map[d.employee][d.shift][d.day_of_month] = d.status return attendance_map @@ -304,86 +286,150 @@ def get_holiday_map(filters: Filters) -> Dict[str, List[Dict]]: return holiday_map -def get_rows(employee_details: Dict, attendance_map: Dict, filters: Filters, holiday_map: Dict) -> List[Dict]: +def get_rows(employee_details: Dict, filters: Filters, holiday_map: Dict, attendance_map: Dict) -> List[Dict]: records = [] default_holiday_list = frappe.get_cached_value('Company', filters.company, 'default_holiday_list') - employees_with_attendance = list(attendance_map.keys()) for employee, details in employee_details.items(): - if employee not in employees_with_attendance: - continue - - row = { - 'employee': employee, - 'employee_name': details.employee_name - } - - if filters.summarized_view: - # set defaults for summarized view - for entry in get_columns(filters): - if entry.get('fieldtype') == 'Float': - row[entry.get('fieldname')] = 0.0 - emp_holiday_list = details.holiday_list or default_holiday_list holidays = holiday_map[emp_holiday_list] - attendance_for_employee = get_attendance_status_for_employee(employee, filters, attendance_map, holidays) - row.update(attendance_for_employee) - if filters.summarized_view: + attendance = get_attendance_status_for_summarized_view(employee, filters, holidays) + if not attendance: + continue + leave_summary = get_leave_summary(employee, filters) entry_exits_summary = get_entry_exits_summary(employee, filters) + row = {'employee': employee, 'employee_name': details.employee_name} + set_defaults_for_summarized_view(filters, row) + row.update(attendance) row.update(leave_summary) row.update(entry_exits_summary) - records.append(row) + records.append(row) + else: + employee_attendance = attendance_map.get(employee) + if not employee_attendance: + continue + + attendance_for_employee = get_attendance_status_for_detailed_view(employee, filters, employee_attendance, holidays) + # set employee details in the first row + attendance_for_employee[0].update({ + 'employee': employee, + 'employee_name': details.employee_name + }) + + records.extend(attendance_for_employee) return records -def get_attendance_status_for_employee(employee: str, filters: Filters, attendance_map: Dict, holidays: List) -> Dict: - """Returns dict of attendance status for employee - - for summarized view: {'total_present': 1.5, 'total_leaves': 0.5, 'total_absent': 13.5, 'total_holidays': 8, 'unmarked_days': 5} - - for detailed view (day wise): {1: 'A', 2: 'P', 3: 'A'....} +def set_defaults_for_summarized_view(filters, row): + for entry in get_columns(filters): + if entry.get('fieldtype') == 'Float': + row[entry.get('fieldname')] = 0.0 + + +def get_attendance_status_for_summarized_view(employee: str, filters: Filters, holidays: List) -> Dict: + """Returns dict of attendance status for employee like + {'total_present': 1.5, 'total_leaves': 0.5, 'total_absent': 13.5, 'total_holidays': 8, 'unmarked_days': 5} """ - emp_attendance = {} + summary, attendance_days = get_attendance_summary_and_days(employee, filters) + if not any(summary.values()): + return {} total_days = get_total_days_in_month(filters) - totals = {'total_present': 0, 'total_leaves': 0, 'total_absent': 0, 'total_holidays': 0, 'unmarked_days': 0} + total_holidays = total_unmarked_days = 0 for day in range(1, total_days + 1): - status = None - employee_attendance = attendance_map.get(employee) - if employee_attendance: - status = employee_attendance.get(day) + if day in attendance_days: + continue - if status is None and holidays: - status = get_holiday_status(day, holidays) + status = get_holiday_status(day, holidays) + if status in ['Weekly Off', 'Holiday']: + total_holidays += 1 + elif not status: + total_unmarked_days += 1 + + return { + 'total_present': summary.total_present + summary.total_half_days, + 'total_leaves': summary.total_leaves + summary.total_half_days, + 'total_absent': summary.total_absent + summary.total_half_days, + 'total_holidays': total_holidays, + 'unmarked_days': total_unmarked_days + } + + +def get_attendance_summary_and_days(employee: str, filters: Filters) -> Tuple[Dict, List]: + Attendance = frappe.qb.DocType('Attendance') + + present_case = frappe.qb.terms.Case().when(((Attendance.status == 'Present') | (Attendance.status == 'Work From Home')), 1).else_(0) + sum_present = Sum(present_case).as_('total_present') + + absent_case = frappe.qb.terms.Case().when(Attendance.status == 'Absent', 1).else_(0) + sum_absent = Sum(absent_case).as_('total_absent') + + leave_case = frappe.qb.terms.Case().when(Attendance.status == 'On Leave', 1).else_(0) + sum_leave = Sum(leave_case).as_('total_leaves') + + half_day_case = frappe.qb.terms.Case().when(Attendance.status == 'Half Day', 0.5).else_(0) + sum_half_day = Sum(half_day_case).as_('total_half_days') + + summary = ( + frappe.qb.from_(Attendance) + .select( + sum_present, sum_absent, sum_leave, sum_half_day, + ).where( + (Attendance.docstatus == 1) + & (Attendance.employee == employee) + & (Attendance.company == filters.company) + & (Extract('month', Attendance.attendance_date) == filters.month) + & (Extract('year', Attendance.attendance_date) == filters.year) + ) + ).run(as_dict=True) + + days = ( + frappe.qb.from_(Attendance) + .select(Extract('day', Attendance.attendance_date).as_('day_of_month')) + .distinct() + .where( + (Attendance.docstatus == 1) + & (Attendance.employee == employee) + & (Attendance.company == filters.company) + & (Extract('month', Attendance.attendance_date) == filters.month) + & (Extract('year', Attendance.attendance_date) == filters.year) + ) + ).run(pluck=True) + + return summary[0], days + + +def get_attendance_status_for_detailed_view(employee: str, filters: Filters, employee_attendance: Dict, holidays: List) -> List[Dict]: + """Returns list of shift-wise attendance status for employee + [ + {'shift': 'Morning Shift', 1: 'A', 2: 'P', 3: 'A'....}, + {'shift': 'Evening Shift', 1: 'P', 2: 'A', 3: 'P'....} + ] + """ + total_days = get_total_days_in_month(filters) + attendance_values = [] + + for shift, status_dict in employee_attendance.items(): + row = {'shift': shift} + + for day in range(1, total_days + 1): + status = status_dict.get(day) + if status is None and holidays: + status = get_holiday_status(day, holidays) - if filters.summarized_view: - if status in ['Present', 'Work From Home']: - totals['total_present'] += 1 - elif status in ['Weekly Off', 'Holiday']: - totals['total_holidays'] += 1 - elif status == 'Absent': - totals['total_absent'] += 1 - elif status == 'On Leave': - totals['total_leaves'] += 1 - elif status == 'Half Day': - totals['total_present'] += 0.5 - totals['total_absent'] += 0.5 - totals['total_leaves'] += 0.5 - elif not status: - totals['unmarked_days'] += 1 - else: abbr = status_map.get(status, '') - emp_attendance[day] = abbr + row[day] = abbr - if filters.summarized_view: - emp_attendance.update(totals) + attendance_values.append(row) - return emp_attendance + return attendance_values def get_holiday_status(day: int, holidays: List) -> str: @@ -411,6 +457,7 @@ def get_leave_summary(employee: str, filters: Filters) -> Dict[str, float]: .select(Attendance.leave_type, sum_leave_days) .where( (Attendance.employee == employee) + & (Attendance.docstatus == 1) & (Attendance.company == filters.company) & ((Attendance.leave_type.isnotnull()) | (Attendance.leave_type != '')) & (Extract('month', Attendance.attendance_date) == filters.month) @@ -442,7 +489,8 @@ def get_entry_exits_summary(employee: str, filters: Filters) -> Dict[str, float] frappe.qb.from_(Attendance) .select(count_late_entries, count_early_exits) .where( - (Attendance.employee == employee) + (Attendance.docstatus == 1) + & (Attendance.employee == employee) & (Attendance.company == filters.company) & (Extract('month', Attendance.attendance_date) == filters.month) & (Extract('year', Attendance.attendance_date) == filters.year) @@ -481,20 +529,19 @@ def get_chart_data(attendance_map: Dict, filters: Filters) -> Dict: labels.append(day['label']) total_absent_on_day = total_leaves_on_day = total_present_on_day = 0 - for employee, attendance in attendance_map.items(): - attendance_on_day = attendance.get(day['fieldname']) - if not attendance_on_day: - continue + for employee, attendance_dict in attendance_map.items(): + for shift, attendance in attendance_dict.items(): + attendance_on_day = attendance.get(day['fieldname']) - if attendance_on_day == 'Absent': - total_absent_on_day += 1 - elif attendance_on_day in ['Present', 'Work From Home']: - total_present_on_day += 1 - elif attendance_on_day == 'Half Day': - total_present_on_day += 0.5 - total_leaves_on_day += 0.5 - elif attendance_on_day == 'On Leave': - total_leaves_on_day += 1 + if attendance_on_day == 'Absent': + total_absent_on_day += 1 + elif attendance_on_day in ['Present', 'Work From Home']: + total_present_on_day += 1 + elif attendance_on_day == 'Half Day': + total_present_on_day += 0.5 + total_leaves_on_day += 0.5 + elif attendance_on_day == 'On Leave': + total_leaves_on_day += 1 absent.append(total_absent_on_day) present.append(total_present_on_day) @@ -511,6 +558,4 @@ def get_chart_data(attendance_map: Dict, filters: Filters) -> Dict: }, 'type': 'line', 'colors': ['red', 'green', 'blue'], - } - - return chart \ No newline at end of file + } \ No newline at end of file From acb27430ac189c156aeeee616e7a1d4ed9284e92 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Wed, 30 Mar 2022 15:14:31 +0530 Subject: [PATCH 14/31] test: monthly attendance sheet --- .../test_monthly_attendance_sheet.py | 158 +++++++++++++++++- 1 file changed, 149 insertions(+), 9 deletions(-) diff --git a/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py b/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py index 9f2babb227..cc899eba8f 100644 --- a/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py +++ b/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py @@ -1,18 +1,27 @@ import frappe from dateutil.relativedelta import relativedelta from frappe.tests.utils import FrappeTestCase -from frappe.utils import now_datetime +from frappe.utils import get_year_ending, get_year_start, getdate, now_datetime from erpnext.hr.doctype.attendance.attendance import mark_attendance from erpnext.hr.doctype.employee.test_employee import make_employee -from erpnext.hr.report.monthly_attendance_sheet.monthly_attendance_sheet import execute +from erpnext.hr.doctype.holiday_list.test_holiday_list import set_holiday_list +from erpnext.hr.doctype.leave_application.test_leave_application import make_allocation_record +from erpnext.hr.report.monthly_attendance_sheet.monthly_attendance_sheet import ( + execute, + get_total_days_in_month, +) +from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_leave_application + +test_dependencies = ["Shift Type"] class TestMonthlyAttendanceSheet(FrappeTestCase): def setUp(self): - self.employee = make_employee("test_employee@example.com") - frappe.db.delete("Attendance", {"employee": self.employee}) + self.employee = make_employee("test_employee@example.com", company="_Test Company") + frappe.db.delete("Attendance") + @set_holiday_list("Salary Slip Test Holiday List", "_Test Company") def test_monthly_attendance_sheet_report(self): now = now_datetime() previous_month = now.month - 1 @@ -35,13 +44,144 @@ class TestMonthlyAttendanceSheet(FrappeTestCase): report = execute(filters=filters) record = report[1][0] - datasets = report[3]['data']['datasets'] - absent = datasets[0]['values'] - present = datasets[1]['values'] - leaves = datasets[2]['values'] + datasets = report[3]["data"]["datasets"] + absent = datasets[0]["values"] + present = datasets[1]["values"] + leaves = datasets[2]["values"] # ensure correct attendance is reflect on the report - self.assertEqual(self.employee, record.get('employee')) + self.assertEqual(self.employee, record.get("employee")) self.assertEqual(absent[0], 1) self.assertEqual(present[1], 1) self.assertEqual(leaves[2], 1) + + @set_holiday_list("Salary Slip Test Holiday List", "_Test Company") + def test_monthly_attendance_sheet_with_detailed_view(self): + now = now_datetime() + previous_month = now.month - 1 + previous_month_first = now.replace(day=1).replace(month=previous_month).date() + + company = frappe.db.get_value("Employee", self.employee, "company") + + # attendance with shift + mark_attendance(self.employee, previous_month_first, "Absent", "Day Shift") + mark_attendance( + self.employee, previous_month_first + relativedelta(days=1), "Present", "Day Shift" + ) + + # attendance without shift + mark_attendance(self.employee, previous_month_first + relativedelta(days=2), "On Leave") + mark_attendance(self.employee, previous_month_first + relativedelta(days=3), "Present") + + filters = frappe._dict( + { + "month": previous_month, + "year": now.year, + "company": company, + } + ) + report = execute(filters=filters) + + day_shift_row = report[1][0] + row_without_shift = report[1][1] + + self.assertEqual(day_shift_row["shift"], "Day Shift") + self.assertEqual(day_shift_row[1], "A") # absent on the 1st day of the month + self.assertEqual(day_shift_row[2], "P") # present on the 2nd day + + self.assertEqual(row_without_shift["shift"], None) + self.assertEqual(row_without_shift[3], "L") # on leave on the 3rd day + self.assertEqual(row_without_shift[4], "P") # present on the 4th day + + @set_holiday_list("Salary Slip Test Holiday List", "_Test Company") + def test_monthly_attendance_sheet_with_summarized_view(self): + now = now_datetime() + previous_month = now.month - 1 + previous_month_first = now.replace(day=1).replace(month=previous_month).date() + + company = frappe.db.get_value("Employee", self.employee, "company") + + # attendance with shift + mark_attendance(self.employee, previous_month_first, "Absent", "Day Shift") + mark_attendance( + self.employee, previous_month_first + relativedelta(days=1), "Present", "Day Shift" + ) + + mark_attendance( + self.employee, previous_month_first + relativedelta(days=3), "Present" + ) # attendance without shift + mark_attendance( + self.employee, previous_month_first + relativedelta(days=4), "Present", late_entry=1 + ) # late entry + mark_attendance( + self.employee, previous_month_first + relativedelta(days=5), "Present", early_exit=1 + ) # early exit + + leave_application = get_leave_application(self.employee) + + filters = frappe._dict( + {"month": previous_month, "year": now.year, "company": company, "summarized_view": 1} + ) + report = execute(filters=filters) + + row = report[1][0] + self.assertEqual(row["employee"], self.employee) + self.assertEqual(row["total_present"], 4) + self.assertEqual(row["total_absent"], 1) + self.assertEqual(row["total_leaves"], leave_application.total_leave_days) + + self.assertEqual(row["_test_leave_type"], leave_application.total_leave_days) + self.assertEqual(row["total_late_entries"], 1) + self.assertEqual(row["total_early_exits"], 1) + + @set_holiday_list("Salary Slip Test Holiday List", "_Test Company") + def test_attendance_with_group_by_filter(self): + now = now_datetime() + previous_month = now.month - 1 + previous_month_first = now.replace(day=1).replace(month=previous_month).date() + + company = frappe.db.get_value("Employee", self.employee, "company") + + # attendance with shift + mark_attendance(self.employee, previous_month_first, "Absent", "Day Shift") + mark_attendance( + self.employee, previous_month_first + relativedelta(days=1), "Present", "Day Shift" + ) + + # attendance without shift + mark_attendance(self.employee, previous_month_first + relativedelta(days=2), "On Leave") + mark_attendance(self.employee, previous_month_first + relativedelta(days=3), "Present") + + filters = frappe._dict( + {"month": previous_month, "year": now.year, "company": company, "group_by": "Department"} + ) + report = execute(filters=filters) + + department = frappe.db.get_value("Employee", self.employee, "department") + department_row = report[1][0] + self.assertIn(department, department_row["department"]) + + day_shift_row = report[1][1] + row_without_shift = report[1][2] + + self.assertEqual(day_shift_row["shift"], "Day Shift") + self.assertEqual(day_shift_row[1], "A") # absent on the 1st day of the month + self.assertEqual(day_shift_row[2], "P") # present on the 2nd day + + self.assertEqual(row_without_shift["shift"], None) + self.assertEqual(row_without_shift[3], "L") # on leave on the 3rd day + self.assertEqual(row_without_shift[4], "P") # present on the 4th day + + +def get_leave_application(employee): + now = now_datetime() + previous_month = now.month - 1 + + date = getdate() + year_start = getdate(get_year_start(date)) + year_end = getdate(get_year_ending(date)) + make_allocation_record(employee=employee, from_date=year_start, to_date=year_end) + + from_date = now.replace(day=7).replace(month=previous_month).date() + to_date = now.replace(day=8).replace(month=previous_month).date() + return make_leave_application(employee, from_date, to_date, "_Test Leave Type") From baec607ff5905b1c67531096a9cf50ec7ff00a5d Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Wed, 30 Mar 2022 15:23:13 +0530 Subject: [PATCH 15/31] style: format code with black --- erpnext/hr/doctype/attendance/attendance.py | 88 ++-- .../employee_checkin/employee_checkin.py | 22 +- .../shift_assignment/shift_assignment.py | 280 +++++++----- erpnext/hr/doctype/shift_type/shift_type.py | 76 ++-- .../monthly_attendance_sheet.py | 403 ++++++++++-------- .../test_monthly_attendance_sheet.py | 5 +- 6 files changed, 540 insertions(+), 334 deletions(-) diff --git a/erpnext/hr/doctype/attendance/attendance.py b/erpnext/hr/doctype/attendance/attendance.py index ae0f2e7c10..a2487b31ff 100644 --- a/erpnext/hr/doctype/attendance/attendance.py +++ b/erpnext/hr/doctype/attendance/attendance.py @@ -14,6 +14,7 @@ from erpnext.hr.utils import get_holiday_dates_for_employee, validate_active_emp class DuplicateAttendanceError(frappe.ValidationError): pass + class Attendance(Document): def validate(self): from erpnext.controllers.status_updater import validate_status @@ -39,12 +40,20 @@ class Attendance(Document): frappe.throw(_("Attendance date can not be less than employee's joining date")) def validate_duplicate_record(self): - duplicate = get_duplicate_attendance_record(self.employee, self.attendance_date, self.shift, self.name) + duplicate = get_duplicate_attendance_record( + self.employee, self.attendance_date, self.shift, self.name + ) if duplicate: - frappe.throw(_("Attendance for employee {0} is already marked for the date {1}: {2}").format( - frappe.bold(self.employee), frappe.bold(self.attendance_date), get_link_to_form("Attendance", duplicate[0].name)), - title=_("Duplicate Attendance"), exc=DuplicateAttendanceError) + frappe.throw( + _("Attendance for employee {0} is already marked for the date {1}: {2}").format( + frappe.bold(self.employee), + frappe.bold(self.attendance_date), + get_link_to_form("Attendance", duplicate[0].name), + ), + title=_("Duplicate Attendance"), + exc=DuplicateAttendanceError, + ) def validate_employee_status(self): if frappe.db.get_value("Employee", self.employee, "status") == "Inactive": @@ -101,26 +110,29 @@ def get_duplicate_attendance_record(employee, attendance_date, shift, name=None) attendance = frappe.qb.DocType("Attendance") query = ( frappe.qb.from_(attendance) - .select(attendance.name) - .where( - (attendance.employee == employee) - & (attendance.docstatus < 2) - ) + .select(attendance.name) + .where((attendance.employee == employee) & (attendance.docstatus < 2)) ) if shift: query = query.where( - Criterion.any([ - Criterion.all([ - ((attendance.shift.isnull()) | (attendance.shift == "")), - (attendance.attendance_date == attendance_date) - ]), - Criterion.all([ - ((attendance.shift.isnotnull()) | (attendance.shift != "")), - (attendance.attendance_date == attendance_date), - (attendance.shift == shift) - ]) - ]) + Criterion.any( + [ + Criterion.all( + [ + ((attendance.shift.isnull()) | (attendance.shift == "")), + (attendance.attendance_date == attendance_date), + ] + ), + Criterion.all( + [ + ((attendance.shift.isnotnull()) | (attendance.shift != "")), + (attendance.attendance_date == attendance_date), + (attendance.shift == shift), + ] + ), + ] + ) ) else: query = query.where((attendance.attendance_date == attendance_date)) @@ -167,18 +179,32 @@ def add_attendance(events, start, end, conditions=None): if e not in events: events.append(e) -def mark_attendance(employee, attendance_date, status, shift=None, leave_type=None, ignore_validate=False): + +def mark_attendance( + employee, + attendance_date, + status, + shift=None, + leave_type=None, + ignore_validate=False, + late_entry=False, + early_exit=False, +): if not get_duplicate_attendance_record(employee, attendance_date, shift): - company = frappe.db.get_value('Employee', employee, 'company') - attendance = frappe.get_doc({ - 'doctype': 'Attendance', - 'employee': employee, - 'attendance_date': attendance_date, - 'status': status, - 'company': company, - 'shift': shift, - 'leave_type': leave_type - }) + company = frappe.db.get_value("Employee", employee, "company") + attendance = frappe.get_doc( + { + "doctype": "Attendance", + "employee": employee, + "attendance_date": attendance_date, + "status": status, + "company": company, + "shift": shift, + "leave_type": leave_type, + "late_entry": late_entry, + "early_exit": early_exit, + } + ) attendance.flags.ignore_validate = ignore_validate attendance.insert() attendance.submit() diff --git a/erpnext/hr/doctype/employee_checkin/employee_checkin.py b/erpnext/hr/doctype/employee_checkin/employee_checkin.py index a9ac60d1be..81c9a46059 100644 --- a/erpnext/hr/doctype/employee_checkin/employee_checkin.py +++ b/erpnext/hr/doctype/employee_checkin/employee_checkin.py @@ -31,11 +31,21 @@ class EmployeeCheckin(Document): ) def fetch_shift(self): - shift_actual_timings = get_actual_start_end_datetime_of_shift(self.employee, get_datetime(self.time), True) + shift_actual_timings = get_actual_start_end_datetime_of_shift( + self.employee, get_datetime(self.time), True + ) if shift_actual_timings: - if shift_actual_timings.shift_type.determine_check_in_and_check_out == 'Strictly based on Log Type in Employee Checkin' \ - and not self.log_type and not self.skip_auto_attendance: - frappe.throw(_('Log Type is required for check-ins falling in the shift: {0}.').format(shift_actual_timings.shift_type.name)) + if ( + shift_actual_timings.shift_type.determine_check_in_and_check_out + == "Strictly based on Log Type in Employee Checkin" + and not self.log_type + and not self.skip_auto_attendance + ): + frappe.throw( + _("Log Type is required for check-ins falling in the shift: {0}.").format( + shift_actual_timings.shift_type.name + ) + ) if not self.attendance: self.shift = shift_actual_timings.shift_type.name self.shift_actual_start = shift_actual_timings.actual_start @@ -125,8 +135,8 @@ def mark_attendance_and_link_log( ("1", log_names), ) return None - elif attendance_status in ('Present', 'Absent', 'Half Day'): - employee_doc = frappe.get_doc('Employee', employee) + elif attendance_status in ("Present", "Absent", "Half Day"): + employee_doc = frappe.get_doc("Employee", employee) if not get_duplicate_attendance_record(employee, attendance_date, shift): doc_dict = { "doctype": "Attendance", diff --git a/erpnext/hr/doctype/shift_assignment/shift_assignment.py b/erpnext/hr/doctype/shift_assignment/shift_assignment.py index 768a86258e..fd0b4d5988 100644 --- a/erpnext/hr/doctype/shift_assignment/shift_assignment.py +++ b/erpnext/hr/doctype/shift_assignment/shift_assignment.py @@ -19,6 +19,7 @@ from erpnext.hr.utils import validate_active_employee class OverlappingShiftError(frappe.ValidationError): pass + class ShiftAssignment(Document): def validate(self): validate_active_employee(self.employee) @@ -42,27 +43,35 @@ class ShiftAssignment(Document): shift = frappe.qb.DocType("Shift Assignment") query = ( frappe.qb.from_(shift) - .select(shift.name, shift.shift_type, shift.start_date, shift.end_date, shift.docstatus, shift.status) - .where( - (shift.employee == self.employee) - & (shift.docstatus == 1) - & (shift.name != self.name) - & (shift.status == "Active") - ) + .select( + shift.name, shift.shift_type, shift.start_date, shift.end_date, shift.docstatus, shift.status + ) + .where( + (shift.employee == self.employee) + & (shift.docstatus == 1) + & (shift.name != self.name) + & (shift.status == "Active") + ) ) if self.end_date: query = query.where( - Criterion.any([ - Criterion.any([ - shift.end_date.isnull(), - ((self.start_date >= shift.start_date) & (self.start_date <= shift.end_date)) - ]), - Criterion.any([ - ((self.end_date >= shift.start_date) & (self.end_date <= shift.end_date)), - shift.start_date.between(self.start_date, self.end_date) - ]) - ]) + Criterion.any( + [ + Criterion.any( + [ + shift.end_date.isnull(), + ((self.start_date >= shift.start_date) & (self.start_date <= shift.end_date)), + ] + ), + Criterion.any( + [ + ((self.end_date >= shift.start_date) & (self.end_date <= shift.end_date)), + shift.start_date.between(self.start_date, self.end_date), + ] + ), + ] + ) ) else: query = query.where( @@ -73,12 +82,27 @@ class ShiftAssignment(Document): return query.run(as_dict=True) def has_overlapping_timings(self, overlapping_shift): - curr_shift = frappe.db.get_value("Shift Type", self.shift_type, ["start_time", "end_time"], as_dict=True) - overlapping_shift = frappe.db.get_value("Shift Type", overlapping_shift, ["start_time", "end_time"], as_dict=True) + curr_shift = frappe.db.get_value( + "Shift Type", self.shift_type, ["start_time", "end_time"], as_dict=True + ) + overlapping_shift = frappe.db.get_value( + "Shift Type", overlapping_shift, ["start_time", "end_time"], as_dict=True + ) - if ((curr_shift.start_time > overlapping_shift.start_time and curr_shift.start_time < overlapping_shift.end_time) or - (curr_shift.end_time > overlapping_shift.start_time and curr_shift.end_time < overlapping_shift.end_time) or - (curr_shift.start_time <= overlapping_shift.start_time and curr_shift.end_time >= overlapping_shift.end_time)): + if ( + ( + curr_shift.start_time > overlapping_shift.start_time + and curr_shift.start_time < overlapping_shift.end_time + ) + or ( + curr_shift.end_time > overlapping_shift.start_time + and curr_shift.end_time < overlapping_shift.end_time + ) + or ( + curr_shift.start_time <= overlapping_shift.start_time + and curr_shift.end_time >= overlapping_shift.end_time + ) + ): return True return False @@ -87,14 +111,20 @@ class ShiftAssignment(Document): msg = None if shift_details.docstatus == 1 and shift_details.status == "Active": if shift_details.start_date and shift_details.end_date: - msg = _("Employee {0} already has an active Shift {1}: {2} from {3} to {4}").format(frappe.bold(self.employee), frappe.bold(self.shift_type), + msg = _("Employee {0} already has an active Shift {1}: {2} from {3} to {4}").format( + frappe.bold(self.employee), + frappe.bold(self.shift_type), get_link_to_form("Shift Assignment", shift_details.name), getdate(self.start_date).strftime("%d-%m-%Y"), - getdate(self.end_date).strftime("%d-%m-%Y")) + getdate(self.end_date).strftime("%d-%m-%Y"), + ) else: - msg = _("Employee {0} already has an active Shift {1}: {2} from {3}").format(frappe.bold(self.employee), frappe.bold(self.shift_type), + msg = _("Employee {0} already has an active Shift {1}: {2} from {3}").format( + frappe.bold(self.employee), + frappe.bold(self.shift_type), get_link_to_form("Shift Assignment", shift_details.name), - getdate(self.start_date).strftime("%d-%m-%Y")) + getdate(self.start_date).strftime("%d-%m-%Y"), + ) if msg: frappe.throw(msg, title=_("Overlapping Shifts"), exc=OverlappingShiftError) @@ -180,10 +210,14 @@ def get_shift_for_time(shifts: List[Dict], for_timestamp: datetime) -> Dict: for entry in shifts: shift_details = get_shift_details(entry.shift_type, for_timestamp=for_timestamp) - if get_datetime(shift_details.actual_start) <= get_datetime(for_timestamp) <= get_datetime(shift_details.actual_end): + if ( + get_datetime(shift_details.actual_start) + <= get_datetime(for_timestamp) + <= get_datetime(shift_details.actual_end) + ): valid_shifts.append(shift_details) - valid_shifts.sort(key=lambda x: x['actual_start']) + valid_shifts.sort(key=lambda x: x["actual_start"]) if len(valid_shifts) > 1: for i in range(len(valid_shifts) - 1): @@ -193,8 +227,16 @@ def get_shift_for_time(shifts: List[Dict], for_timestamp: datetime) -> Dict: next_shift = valid_shifts[i + 1] if curr_shift and next_shift: - next_shift.actual_start = curr_shift.end_datetime if next_shift.actual_start < curr_shift.end_datetime else next_shift.actual_start - curr_shift.actual_end = next_shift.actual_start if curr_shift.actual_end > next_shift.actual_start else curr_shift.actual_end + next_shift.actual_start = ( + curr_shift.end_datetime + if next_shift.actual_start < curr_shift.end_datetime + else next_shift.actual_start + ) + curr_shift.actual_end = ( + next_shift.actual_start + if curr_shift.actual_end > next_shift.actual_start + else curr_shift.actual_end + ) valid_shifts[i] = curr_shift valid_shifts[i + 1] = next_shift @@ -206,23 +248,25 @@ def get_shift_for_time(shifts: List[Dict], for_timestamp: datetime) -> Dict: def get_shifts_for_date(employee: str, for_timestamp: datetime) -> List[Dict[str, str]]: """Returns list of shifts with details for given date""" - assignment = frappe.qb.DocType('Shift Assignment') + assignment = frappe.qb.DocType("Shift Assignment") return ( frappe.qb.from_(assignment) - .select(assignment.name, assignment.shift_type) - .where( - (assignment.employee == employee) - & (assignment.docstatus == 1) - & (assignment.status == 'Active') - & (assignment.start_date <= getdate(for_timestamp.date())) - & ( - Criterion.any([ + .select(assignment.name, assignment.shift_type) + .where( + (assignment.employee == employee) + & (assignment.docstatus == 1) + & (assignment.status == "Active") + & (assignment.start_date <= getdate(for_timestamp.date())) + & ( + Criterion.any( + [ assignment.end_date.isnull(), - (assignment.end_date.isnotnull() & (getdate(for_timestamp.date()) >= assignment.end_date)) - ]) + (assignment.end_date.isnotnull() & (getdate(for_timestamp.date()) >= assignment.end_date)), + ] ) ) + ) ).run(as_dict=True) @@ -233,7 +277,12 @@ def get_shift_for_timestamp(employee: str, for_timestamp: datetime) -> Dict: return {} -def get_employee_shift(employee: str, for_timestamp: datetime = None, consider_default_shift: bool = False, next_shift_direction: str = None) -> Dict: +def get_employee_shift( + employee: str, + for_timestamp: datetime = None, + consider_default_shift: bool = False, + next_shift_direction: str = None, +) -> Dict: """Returns a Shift Type for the given employee on the given date. (excluding the holidays) :param employee: Employee for which shift is required. @@ -247,7 +296,7 @@ def get_employee_shift(employee: str, for_timestamp: datetime = None, consider_d shift_details = get_shift_for_timestamp(employee, for_timestamp) # if shift assignment is not found, consider default shift - default_shift = frappe.db.get_value('Employee', employee, 'default_shift') + default_shift = frappe.db.get_value("Employee", employee, "default_shift") if not shift_details and consider_default_shift: shift_details = get_shift_details(default_shift, for_timestamp) @@ -257,38 +306,55 @@ def get_employee_shift(employee: str, for_timestamp: datetime = None, consider_d # if no shift is found, find next or prev shift assignment based on direction if not shift_details and next_shift_direction: - shift_details = get_prev_or_next_shift(employee, for_timestamp, consider_default_shift, default_shift, next_shift_direction) + shift_details = get_prev_or_next_shift( + employee, for_timestamp, consider_default_shift, default_shift, next_shift_direction + ) return shift_details or {} -def get_prev_or_next_shift(employee: str, for_timestamp: datetime, consider_default_shift: bool, - default_shift: str, next_shift_direction: str) -> Dict: +def get_prev_or_next_shift( + employee: str, + for_timestamp: datetime, + consider_default_shift: bool, + default_shift: str, + next_shift_direction: str, +) -> Dict: """Returns a dict of shift details for the next or prev shift based on the next_shift_direction""" MAX_DAYS = 366 shift_details = {} if consider_default_shift and default_shift: - direction = -1 if next_shift_direction == 'reverse' else 1 + direction = -1 if next_shift_direction == "reverse" else 1 for i in range(MAX_DAYS): - date = for_timestamp + timedelta(days=direction*(i+1)) + date = for_timestamp + timedelta(days=direction * (i + 1)) shift_details = get_employee_shift(employee, date, consider_default_shift, None) if shift_details: break else: - direction = '<' if next_shift_direction == 'reverse' else '>' - sort_order = 'desc' if next_shift_direction == 'reverse' else 'asc' - dates = frappe.db.get_all('Shift Assignment', - ['start_date', 'end_date'], - {'employee': employee, 'start_date': (direction, for_timestamp.date()), 'docstatus': 1, 'status': 'Active'}, + direction = "<" if next_shift_direction == "reverse" else ">" + sort_order = "desc" if next_shift_direction == "reverse" else "asc" + dates = frappe.db.get_all( + "Shift Assignment", + ["start_date", "end_date"], + { + "employee": employee, + "start_date": (direction, for_timestamp.date()), + "docstatus": 1, + "status": "Active", + }, as_list=True, - limit=MAX_DAYS, order_by='start_date ' + sort_order) + limit=MAX_DAYS, + order_by="start_date " + sort_order, + ) if dates: for date in dates: if date[1] and date[1] < for_timestamp.date(): continue - shift_details = get_employee_shift(employee, datetime.combine(date[0], for_timestamp.time()), consider_default_shift, None) + shift_details = get_employee_shift( + employee, datetime.combine(date[0], for_timestamp.time()), consider_default_shift, None + ) if shift_details: break @@ -296,7 +362,9 @@ def get_prev_or_next_shift(employee: str, for_timestamp: datetime, consider_defa def is_holiday_date(employee: str, shift_details: Dict) -> bool: - holiday_list_name = frappe.db.get_value('Shift Type', shift_details.shift_type.name, 'holiday_list') + holiday_list_name = frappe.db.get_value( + "Shift Type", shift_details.shift_type.name, "holiday_list" + ) if not holiday_list_name: holiday_list_name = get_holiday_list_for_employee(employee, False) @@ -304,17 +372,23 @@ def is_holiday_date(employee: str, shift_details: Dict) -> bool: return holiday_list_name and is_holiday(holiday_list_name, shift_details.start_datetime.date()) -def get_employee_shift_timings(employee: str, for_timestamp: datetime = None, consider_default_shift: bool = False) -> List[Dict]: +def get_employee_shift_timings( + employee: str, for_timestamp: datetime = None, consider_default_shift: bool = False +) -> List[Dict]: """Returns previous shift, current/upcoming shift, next_shift for the given timestamp and employee""" if for_timestamp is None: for_timestamp = now_datetime() # write and verify a test case for midnight shift. prev_shift = curr_shift = next_shift = None - curr_shift = get_employee_shift(employee, for_timestamp, consider_default_shift, 'forward') + curr_shift = get_employee_shift(employee, for_timestamp, consider_default_shift, "forward") if curr_shift: - next_shift = get_employee_shift(employee, curr_shift.start_datetime + timedelta(days=1), consider_default_shift, 'forward') - prev_shift = get_employee_shift(employee, for_timestamp + timedelta(days=-1), consider_default_shift, 'reverse') + next_shift = get_employee_shift( + employee, curr_shift.start_datetime + timedelta(days=1), consider_default_shift, "forward" + ) + prev_shift = get_employee_shift( + employee, for_timestamp + timedelta(days=-1), consider_default_shift, "reverse" + ) if curr_shift: # adjust actual start and end times if they are overlapping with grace period (before start and after end) @@ -330,26 +404,35 @@ def get_employee_shift_timings(employee: str, for_timestamp: datetime = None, co else prev_shift.actual_end ) if next_shift: - next_shift.actual_start = curr_shift.end_datetime if next_shift.actual_start < curr_shift.end_datetime else next_shift.actual_start - curr_shift.actual_end = next_shift.actual_start if curr_shift.actual_end > next_shift.actual_start else curr_shift.actual_end + next_shift.actual_start = ( + curr_shift.end_datetime + if next_shift.actual_start < curr_shift.end_datetime + else next_shift.actual_start + ) + curr_shift.actual_end = ( + next_shift.actual_start + if curr_shift.actual_end > next_shift.actual_start + else curr_shift.actual_end + ) return prev_shift, curr_shift, next_shift -def get_actual_start_end_datetime_of_shift(employee: str, for_timestamp: datetime, consider_default_shift: bool = False) -> Dict: - """ - Params: - employee (str): Employee name - for_timestamp (datetime, optional): Datetime value of checkin, if not provided considers current datetime - consider_default_shift (bool, optional): Flag (defaults to False) to specify whether to consider - default shift in employee master if no shift assignment is found +def get_actual_start_end_datetime_of_shift( + employee: str, for_timestamp: datetime, consider_default_shift: bool = False +) -> Dict: + """Returns a Dict containing shift details with actual_start and actual_end datetime values + Here 'actual' means taking into account the "begin_check_in_before_shift_start_time" and "allow_check_out_after_shift_end_time". + Empty Dict is returned if the timestamp is outside any actual shift timings. - Returns: - dict: Dict containing shift details with actual_start and actual_end datetime values - Here 'actual' means taking into account the "begin_check_in_before_shift_start_time" and "allow_check_out_after_shift_end_time". - Empty Dict is returned if the timestamp is outside any actual shift timings. + :param employee (str): Employee name + :param for_timestamp (datetime, optional): Datetime value of checkin, if not provided considers current datetime + :param consider_default_shift (bool, optional): Flag (defaults to False) to specify whether to consider + default shift in employee master if no shift assignment is found """ - shift_timings_as_per_timestamp = get_employee_shift_timings(employee, for_timestamp, consider_default_shift) + shift_timings_as_per_timestamp = get_employee_shift_timings( + employee, for_timestamp, consider_default_shift + ) return get_exact_shift(shift_timings_as_per_timestamp, for_timestamp) @@ -381,25 +464,22 @@ def get_exact_shift(shifts: List, for_timestamp: datetime) -> Dict: if timestamp_index: break - if timestamp_index and timestamp_index%2 == 1: - shift_details = shifts[int((timestamp_index-1)/2)] + if timestamp_index and timestamp_index % 2 == 1: + shift_details = shifts[int((timestamp_index - 1) / 2)] return shift_details def get_shift_details(shift_type_name: str, for_timestamp: datetime = None) -> Dict: - """ - Params: - shift_type_name (str): shift type name for which shift_details are required. - for_timestamp (datetime, optional): Datetime value of checkin, if not provided considers current datetime + """Returns a Dict containing shift details with the following data: + 'shift_type' - Object of DocType Shift Type, + 'start_datetime' - datetime of shift start on given timestamp, + 'end_datetime' - datetime of shift end on given timestamp, + 'actual_start' - datetime of shift start after adding 'begin_check_in_before_shift_start_time', + 'actual_end' - datetime of shift end after adding 'allow_check_out_after_shift_end_time' (None is returned if this is zero) - Returns: - dict: Dict containing shift details with the following data: - 'shift_type' - Object of DocType Shift Type, - 'start_datetime' - datetime of shift start on given timestamp, - 'end_datetime' - datetime of shift end on given timestamp, - 'actual_start' - datetime of shift start after adding 'begin_check_in_before_shift_start_time', - 'actual_end' - datetime of shift end after adding 'allow_check_out_after_shift_end_time' (None is returned if this is zero) + :param shift_type_name (str): shift type name for which shift_details are required. + :param for_timestamp (datetime, optional): Datetime value of checkin, if not provided considers current datetime """ if not shift_type_name: return {} @@ -407,8 +487,10 @@ def get_shift_details(shift_type_name: str, for_timestamp: datetime = None) -> D if for_timestamp is None: for_timestamp = now_datetime() - shift_type = frappe.get_doc('Shift Type', shift_type_name) - shift_actual_start = shift_type.start_time - timedelta(minutes=shift_type.begin_check_in_before_shift_start_time) + shift_type = frappe.get_doc("Shift Type", shift_type_name) + shift_actual_start = shift_type.start_time - timedelta( + minutes=shift_type.begin_check_in_before_shift_start_time + ) if shift_type.start_time > shift_type.end_time: # shift spans accross 2 different days @@ -428,13 +510,17 @@ def get_shift_details(shift_type_name: str, for_timestamp: datetime = None) -> D start_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.start_time end_datetime = datetime.combine(for_timestamp, datetime.min.time()) + shift_type.end_time - actual_start = start_datetime - timedelta(minutes=shift_type.begin_check_in_before_shift_start_time) + actual_start = start_datetime - timedelta( + minutes=shift_type.begin_check_in_before_shift_start_time + ) actual_end = end_datetime + timedelta(minutes=shift_type.allow_check_out_after_shift_end_time) - return frappe._dict({ - 'shift_type': shift_type, - 'start_datetime': start_datetime, - 'end_datetime': end_datetime, - 'actual_start': actual_start, - 'actual_end': actual_end - }) + return frappe._dict( + { + "shift_type": shift_type, + "start_datetime": start_datetime, + "end_datetime": end_datetime, + "actual_start": actual_start, + "actual_end": actual_end, + } + ) diff --git a/erpnext/hr/doctype/shift_type/shift_type.py b/erpnext/hr/doctype/shift_type/shift_type.py index dd1dff1bc4..f5689d190f 100644 --- a/erpnext/hr/doctype/shift_type/shift_type.py +++ b/erpnext/hr/doctype/shift_type/shift_type.py @@ -34,19 +34,40 @@ class ShiftType(Document): return filters = { - 'skip_auto_attendance': 0, - 'attendance': ('is', 'not set'), - 'time': ('>=', self.process_attendance_after), - 'shift_actual_end': ('<', self.last_sync_of_checkin), - 'shift': self.name + "skip_auto_attendance": 0, + "attendance": ("is", "not set"), + "time": (">=", self.process_attendance_after), + "shift_actual_end": ("<", self.last_sync_of_checkin), + "shift": self.name, } - logs = frappe.db.get_list('Employee Checkin', fields="*", filters=filters, order_by="employee,time") + logs = frappe.db.get_list( + "Employee Checkin", fields="*", filters=filters, order_by="employee,time" + ) - for key, group in itertools.groupby(logs, key=lambda x: (x['employee'], x['shift_actual_start'])): + 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, late_entry, early_exit, in_time, out_time = self.get_attendance(single_shift_logs) - mark_attendance_and_link_log(single_shift_logs, attendance_status, key[1].date(), - working_hours, late_entry, early_exit, in_time, out_time, self.name) + ( + attendance_status, + working_hours, + late_entry, + early_exit, + in_time, + out_time, + ) = self.get_attendance(single_shift_logs) + + mark_attendance_and_link_log( + single_shift_logs, + attendance_status, + key[1].date(), + working_hours, + late_entry, + early_exit, + in_time, + out_time, + self.name, + ) for employee in self.get_assigned_employee(self.process_attendance_after, True): self.mark_absent_for_dates_with_no_attendance(employee) @@ -54,9 +75,9 @@ class ShiftType(Document): def get_attendance(self, logs): """Return attendance_status, working_hours, late_entry, early_exit, in_time, out_time for a set of logs belonging to a single shift. - Assumption: - 1. These logs belongs to a single shift, single employee and it's not in a holiday date. - 2. Logs are in chronological order + Assumptions: + 1. These logs belongs to a single shift, single employee and it's not in a holiday date. + 2. Logs are in chronological order """ late_entry = early_exit = False total_working_hours, in_time, out_time = calculate_working_hours( @@ -116,8 +137,9 @@ class ShiftType(Document): mark_attendance(employee, date, "Absent", self.name) def get_start_and_end_dates(self, employee): - date_of_joining, relieving_date, employee_creation = frappe.db.get_value("Employee", employee, - ["date_of_joining", "relieving_date", "creation"]) + 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() @@ -126,26 +148,32 @@ class ShiftType(Document): end_date = None shift_details = get_shift_details(self.name, get_datetime(self.last_sync_of_checkin)) - last_shift_time = shift_details.actual_start if shift_details else get_datetime(self.last_sync_of_checkin) + last_shift_time = ( + shift_details.actual_start if shift_details else get_datetime(self.last_sync_of_checkin) + ) - prev_shift = get_employee_shift(employee, last_shift_time - timedelta(days=1), True, 'reverse') + prev_shift = get_employee_shift(employee, last_shift_time - 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() + end_date = ( + min(prev_shift.start_datetime.date(), relieving_date) + if relieving_date + else prev_shift.start_datetime.date() + ) return start_date, end_date def get_assigned_employee(self, from_date=None, consider_default_shift=False): - filters = {'shift_type': self.name, 'docstatus': '1'} + filters = {"shift_type": self.name, "docstatus": "1"} if from_date: - filters['start_date'] = ('>', from_date) + filters["start_date"] = (">", from_date) - assigned_employees = frappe.get_all('Shift Assignment', filters=filters, pluck='employee') + assigned_employees = frappe.get_all("Shift Assignment", filters=filters, pluck="employee") if consider_default_shift: - filters = {'default_shift': self.name, 'status': ['!=', 'Inactive']} - default_shift_employees = frappe.get_all('Employee', filters=filters, pluck='name') + filters = {"default_shift": self.name, "status": ["!=", "Inactive"]} + default_shift_employees = frappe.get_all("Employee", filters=filters, pluck="name") - return list(set(assigned_employees+default_shift_employees)) + return list(set(assigned_employees + default_shift_employees)) return assigned_employees diff --git a/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py b/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py index a98afe41cc..efd2d382d5 100644 --- a/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py +++ b/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py @@ -14,44 +14,47 @@ from frappe.utils import cint, cstr, getdate Filters = frappe._dict status_map = { - 'Present': 'P', - 'Absent': 'A', - 'Half Day': 'HD', - 'Work From Home': 'WFH', - 'On Leave': 'L', - 'Holiday': 'H', - 'Weekly Off': 'WO' + "Present": "P", + "Absent": "A", + "Half Day": "HD", + "Work From Home": "WFH", + "On Leave": "L", + "Holiday": "H", + "Weekly Off": "WO", } -day_abbr = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] +day_abbr = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] -def execute(filters: Optional[Filters] = None) -> Tuple: + +def execute(filters: Optional[Filters] = None) -> Tuple: filters = frappe._dict(filters or {}) if not (filters.month and filters.year): - frappe.throw(_('Please select month and year.')) + frappe.throw(_("Please select month and year.")) attendance_map = get_attendance_map(filters) if not attendance_map: - frappe.msgprint(_('No attendance records found.'), alert=True, indicator='orange') + frappe.msgprint(_("No attendance records found."), alert=True, indicator="orange") return [], [], None, None columns = get_columns(filters) data = get_data(filters, attendance_map) if not data: - frappe.msgprint(_('No attendance records found for this criteria.'), alert=True, indicator='orange') + frappe.msgprint( + _("No attendance records found for this criteria."), alert=True, indicator="orange" + ) return columns, [], None, None - message = get_message() if not filters.summarized_view else '' + message = get_message() if not filters.summarized_view else "" chart = get_chart_data(attendance_map, filters) return columns, data, message, chart def get_message() -> str: - message = '' - colors = ['green', 'red', 'orange', 'green', '#318AD8', '', ''] + message = "" + colors = ["green", "red", "orange", "green", "#318AD8", "", ""] count = 0 for status, abbr in status_map.items(): @@ -70,39 +73,84 @@ def get_columns(filters: Filters) -> List[Dict]: if filters.group_by: columns.append( - {'label': _(filters.group_by), 'fieldname': frappe.scrub(filters.group_by), 'fieldtype': 'Link', 'options': 'Branch', 'width': 120} + { + "label": _(filters.group_by), + "fieldname": frappe.scrub(filters.group_by), + "fieldtype": "Link", + "options": "Branch", + "width": 120, + } ) - columns.extend([ - {'label': _('Employee'), 'fieldname': 'employee', 'fieldtype': 'Link', 'options': 'Employee', 'width': 135}, - {'label': _('Employee Name'), 'fieldname': 'employee_name', 'fieldtype': 'Data', 'width': 120} - ]) + columns.extend( + [ + { + "label": _("Employee"), + "fieldname": "employee", + "fieldtype": "Link", + "options": "Employee", + "width": 135, + }, + {"label": _("Employee Name"), "fieldname": "employee_name", "fieldtype": "Data", "width": 120}, + ] + ) if filters.summarized_view: - columns.extend([ - {'label': _('Total Present'), 'fieldname': 'total_present', 'fieldtype': 'Float', 'width': 110}, - {'label': _('Total Leaves'), 'fieldname': 'total_leaves', 'fieldtype': 'Float', 'width': 110}, - {'label': _('Total Absent'), 'fieldname': 'total_absent', 'fieldtype': 'Float', 'width': 110}, - {'label': _('Total Holidays'), 'fieldname': 'total_holidays', 'fieldtype': 'Float', 'width': 120}, - {'label': _('Unmarked Days'), 'fieldname': 'unmarked_days', 'fieldtype': 'Float', 'width': 130} - ]) + columns.extend( + [ + { + "label": _("Total Present"), + "fieldname": "total_present", + "fieldtype": "Float", + "width": 110, + }, + {"label": _("Total Leaves"), "fieldname": "total_leaves", "fieldtype": "Float", "width": 110}, + {"label": _("Total Absent"), "fieldname": "total_absent", "fieldtype": "Float", "width": 110}, + { + "label": _("Total Holidays"), + "fieldname": "total_holidays", + "fieldtype": "Float", + "width": 120, + }, + { + "label": _("Unmarked Days"), + "fieldname": "unmarked_days", + "fieldtype": "Float", + "width": 130, + }, + ] + ) columns.extend(get_columns_for_leave_types()) - columns.extend([ - {'label': _('Total Late Entries'), 'fieldname': 'total_late_entries', 'fieldtype': 'Float', 'width': 140}, - {'label': _('Total Early Exits'), 'fieldname': 'total_early_exits', 'fieldtype': 'Float', 'width': 140} - ]) + columns.extend( + [ + { + "label": _("Total Late Entries"), + "fieldname": "total_late_entries", + "fieldtype": "Float", + "width": 140, + }, + { + "label": _("Total Early Exits"), + "fieldname": "total_early_exits", + "fieldtype": "Float", + "width": 140, + }, + ] + ) else: - columns.append({'label': _('Shift'), 'fieldname': 'shift', 'fieldtype': 'Data', 'width': 120}) + columns.append({"label": _("Shift"), "fieldname": "shift", "fieldtype": "Data", "width": 120}) columns.extend(get_columns_for_days(filters)) return columns def get_columns_for_leave_types() -> List[Dict]: - leave_types = frappe.db.get_all('Leave Type', pluck='name') + leave_types = frappe.db.get_all("Leave Type", pluck="name") types = [] for entry in leave_types: - types.append({'label': entry, 'fieldname': frappe.scrub(entry), 'fieldtype': 'Float', 'width': 120}) + types.append( + {"label": entry, "fieldname": frappe.scrub(entry), "fieldtype": "Float", "width": 120} + ) return types @@ -111,23 +159,14 @@ def get_columns_for_days(filters: Filters) -> List[Dict]: total_days = get_total_days_in_month(filters) days = [] - for day in range(1, total_days+1): + for day in range(1, total_days + 1): # forms the dates from selected year and month from filters - date = '{}-{}-{}'.format( - cstr(filters.year), - cstr(filters.month), - cstr(day) - ) + date = "{}-{}-{}".format(cstr(filters.year), cstr(filters.month), cstr(day)) # gets abbr from weekday number weekday = day_abbr[getdate(date).weekday()] # sets days as 1 Mon, 2 Tue, 3 Wed - label = '{} {}'.format(cstr(day), weekday) - days.append({ - 'label': label, - 'fieldtype': 'Data', - 'fieldname': day, - 'width': 65 - }) + label = "{} {}".format(cstr(day), weekday) + days.append({"label": label, "fieldtype": "Data", "fieldname": day, "width": 65}) return days @@ -137,7 +176,9 @@ def get_total_days_in_month(filters: Filters) -> int: def get_data(filters: Filters, attendance_map: Dict) -> List[Dict]: - employee_details, group_by_param_values = get_employee_related_details(filters.group_by, filters.company) + employee_details, group_by_param_values = get_employee_related_details( + filters.group_by, filters.company + ) holiday_map = get_holiday_map(filters) data = [] @@ -151,9 +192,7 @@ def get_data(filters: Filters, attendance_map: Dict) -> List[Dict]: records = get_rows(employee_details[value], filters, holiday_map, attendance_map) if records: - data.append({ - group_by_column: frappe.bold(value) - }) + data.append({group_by_column: frappe.bold(value)}) data.extend(records) else: data = get_rows(employee_details, filters, holiday_map, attendance_map) @@ -163,30 +202,31 @@ def get_data(filters: Filters, attendance_map: Dict) -> List[Dict]: def get_attendance_map(filters: Filters) -> Dict: """Returns a dictionary of employee wise attendance map as per shifts for all the days of the month like - { - 'employee1': { - 'Morning Shift': {1: 'Present', 2: 'Absent', ...} - 'Evening Shift': {1: 'Absent', 2: 'Present', ...} - }, - 'employee2': { - 'Afternoon Shift': {1: 'Present', 2: 'Absent', ...} - 'Night Shift': {1: 'Absent', 2: 'Absent', ...} - } - } + { + 'employee1': { + 'Morning Shift': {1: 'Present', 2: 'Absent', ...} + 'Evening Shift': {1: 'Absent', 2: 'Present', ...} + }, + 'employee2': { + 'Afternoon Shift': {1: 'Present', 2: 'Absent', ...} + 'Night Shift': {1: 'Absent', 2: 'Absent', ...} + } + } """ - Attendance = frappe.qb.DocType('Attendance') + Attendance = frappe.qb.DocType("Attendance") query = ( frappe.qb.from_(Attendance) .select( Attendance.employee, - Extract('day', Attendance.attendance_date).as_('day_of_month'), + Extract("day", Attendance.attendance_date).as_("day_of_month"), Attendance.status, - Attendance.shift - ).where( + Attendance.shift, + ) + .where( (Attendance.docstatus == 1) & (Attendance.company == filters.company) - & (Extract('month', Attendance.attendance_date) == filters.month) - & (Extract('year', Attendance.attendance_date) == filters.year) + & (Extract("month", Attendance.attendance_date) == filters.month) + & (Extract("year", Attendance.attendance_date) == filters.year) ) ) if filters.employee: @@ -205,18 +245,23 @@ def get_attendance_map(filters: Filters) -> Dict: def get_employee_related_details(group_by: str, company: str) -> Tuple[Dict, List]: """Returns - 1. nested dict for employee details - 2. list of values for the group by filter - eg: if group by filter is set to "Department" then returns a list like ['HR', 'Support', 'Engineering'] + 1. nested dict for employee details + 2. list of values for the group by filter """ - Employee = frappe.qb.DocType('Employee') + Employee = frappe.qb.DocType("Employee") query = ( frappe.qb.from_(Employee) .select( - Employee.name, Employee.employee_name, Employee.designation, - Employee.grade, Employee.department, Employee.branch, - Employee.company, Employee.holiday_list - ).where(Employee.company == company) + Employee.name, + Employee.employee_name, + Employee.designation, + Employee.grade, + Employee.department, + Employee.branch, + Employee.company, + Employee.holiday_list, + ) + .where(Employee.company == company) ) if group_by: @@ -247,23 +292,23 @@ def get_holiday_map(filters: Filters) -> Dict[str, List[Dict]]: Returns a dict of holidays falling in the filter month and year with list name as key and list of holidays as values like { - 'Holiday List 1': [ - {'day_of_month': '0' , 'weekly_off': 1}, - {'day_of_month': '1', 'weekly_off': 0} - ], - 'Holiday List 2': [ - {'day_of_month': '0' , 'weekly_off': 1}, - {'day_of_month': '1', 'weekly_off': 0} - ] + 'Holiday List 1': [ + {'day_of_month': '0' , 'weekly_off': 1}, + {'day_of_month': '1', 'weekly_off': 0} + ], + 'Holiday List 2': [ + {'day_of_month': '0' , 'weekly_off': 1}, + {'day_of_month': '1', 'weekly_off': 0} + ] } """ # add default holiday list too - holiday_lists = frappe.db.get_all('Holiday List', pluck='name') - default_holiday_list = frappe.get_cached_value('Company', filters.company, 'default_holiday_list') + holiday_lists = frappe.db.get_all("Holiday List", pluck="name") + default_holiday_list = frappe.get_cached_value("Company", filters.company, "default_holiday_list") holiday_lists.append(default_holiday_list) holiday_map = frappe._dict() - Holiday = frappe.qb.DocType('Holiday') + Holiday = frappe.qb.DocType("Holiday") for d in holiday_lists: if not d: @@ -271,13 +316,11 @@ def get_holiday_map(filters: Filters) -> Dict[str, List[Dict]]: holidays = ( frappe.qb.from_(Holiday) - .select( - Extract('day', Holiday.holiday_date).as_('day_of_month'), - Holiday.weekly_off - ).where( + .select(Extract("day", Holiday.holiday_date).as_("day_of_month"), Holiday.weekly_off) + .where( (Holiday.parent == d) - & (Extract('month', Holiday.holiday_date) == filters.month) - & (Extract('year', Holiday.holiday_date) == filters.year) + & (Extract("month", Holiday.holiday_date) == filters.month) + & (Extract("year", Holiday.holiday_date) == filters.year) ) ).run(as_dict=True) @@ -286,13 +329,15 @@ def get_holiday_map(filters: Filters) -> Dict[str, List[Dict]]: return holiday_map -def get_rows(employee_details: Dict, filters: Filters, holiday_map: Dict, attendance_map: Dict) -> List[Dict]: +def get_rows( + employee_details: Dict, filters: Filters, holiday_map: Dict, attendance_map: Dict +) -> List[Dict]: records = [] - default_holiday_list = frappe.get_cached_value('Company', filters.company, 'default_holiday_list') + default_holiday_list = frappe.get_cached_value("Company", filters.company, "default_holiday_list") for employee, details in employee_details.items(): emp_holiday_list = details.holiday_list or default_holiday_list - holidays = holiday_map[emp_holiday_list] + holidays = holiday_map.get(emp_holiday_list) if filters.summarized_view: attendance = get_attendance_status_for_summarized_view(employee, filters, holidays) @@ -302,7 +347,7 @@ def get_rows(employee_details: Dict, filters: Filters, holiday_map: Dict, attend leave_summary = get_leave_summary(employee, filters) entry_exits_summary = get_entry_exits_summary(employee, filters) - row = {'employee': employee, 'employee_name': details.employee_name} + row = {"employee": employee, "employee_name": details.employee_name} set_defaults_for_summarized_view(filters, row) row.update(attendance) row.update(leave_summary) @@ -314,12 +359,13 @@ def get_rows(employee_details: Dict, filters: Filters, holiday_map: Dict, attend if not employee_attendance: continue - attendance_for_employee = get_attendance_status_for_detailed_view(employee, filters, employee_attendance, holidays) + attendance_for_employee = get_attendance_status_for_detailed_view( + employee, filters, employee_attendance, holidays + ) # set employee details in the first row - attendance_for_employee[0].update({ - 'employee': employee, - 'employee_name': details.employee_name - }) + attendance_for_employee[0].update( + {"employee": employee, "employee_name": details.employee_name} + ) records.extend(attendance_for_employee) @@ -328,13 +374,15 @@ def get_rows(employee_details: Dict, filters: Filters, holiday_map: Dict, attend def set_defaults_for_summarized_view(filters, row): for entry in get_columns(filters): - if entry.get('fieldtype') == 'Float': - row[entry.get('fieldname')] = 0.0 + if entry.get("fieldtype") == "Float": + row[entry.get("fieldname")] = 0.0 -def get_attendance_status_for_summarized_view(employee: str, filters: Filters, holidays: List) -> Dict: +def get_attendance_status_for_summarized_view( + employee: str, filters: Filters, holidays: List +) -> Dict: """Returns dict of attendance status for employee like - {'total_present': 1.5, 'total_leaves': 0.5, 'total_absent': 13.5, 'total_holidays': 8, 'unmarked_days': 5} + {'total_present': 1.5, 'total_leaves': 0.5, 'total_absent': 13.5, 'total_holidays': 8, 'unmarked_days': 5} """ summary, attendance_days = get_attendance_summary_and_days(employee, filters) if not any(summary.values()): @@ -348,83 +396,93 @@ def get_attendance_status_for_summarized_view(employee: str, filters: Filters, h continue status = get_holiday_status(day, holidays) - if status in ['Weekly Off', 'Holiday']: + if status in ["Weekly Off", "Holiday"]: total_holidays += 1 elif not status: total_unmarked_days += 1 return { - 'total_present': summary.total_present + summary.total_half_days, - 'total_leaves': summary.total_leaves + summary.total_half_days, - 'total_absent': summary.total_absent + summary.total_half_days, - 'total_holidays': total_holidays, - 'unmarked_days': total_unmarked_days + "total_present": summary.total_present + summary.total_half_days, + "total_leaves": summary.total_leaves + summary.total_half_days, + "total_absent": summary.total_absent + summary.total_half_days, + "total_holidays": total_holidays, + "unmarked_days": total_unmarked_days, } def get_attendance_summary_and_days(employee: str, filters: Filters) -> Tuple[Dict, List]: - Attendance = frappe.qb.DocType('Attendance') + Attendance = frappe.qb.DocType("Attendance") - present_case = frappe.qb.terms.Case().when(((Attendance.status == 'Present') | (Attendance.status == 'Work From Home')), 1).else_(0) - sum_present = Sum(present_case).as_('total_present') + present_case = ( + frappe.qb.terms.Case() + .when(((Attendance.status == "Present") | (Attendance.status == "Work From Home")), 1) + .else_(0) + ) + sum_present = Sum(present_case).as_("total_present") - absent_case = frappe.qb.terms.Case().when(Attendance.status == 'Absent', 1).else_(0) - sum_absent = Sum(absent_case).as_('total_absent') + absent_case = frappe.qb.terms.Case().when(Attendance.status == "Absent", 1).else_(0) + sum_absent = Sum(absent_case).as_("total_absent") - leave_case = frappe.qb.terms.Case().when(Attendance.status == 'On Leave', 1).else_(0) - sum_leave = Sum(leave_case).as_('total_leaves') + leave_case = frappe.qb.terms.Case().when(Attendance.status == "On Leave", 1).else_(0) + sum_leave = Sum(leave_case).as_("total_leaves") - half_day_case = frappe.qb.terms.Case().when(Attendance.status == 'Half Day', 0.5).else_(0) - sum_half_day = Sum(half_day_case).as_('total_half_days') + half_day_case = frappe.qb.terms.Case().when(Attendance.status == "Half Day", 0.5).else_(0) + sum_half_day = Sum(half_day_case).as_("total_half_days") summary = ( frappe.qb.from_(Attendance) .select( - sum_present, sum_absent, sum_leave, sum_half_day, - ).where( + sum_present, + sum_absent, + sum_leave, + sum_half_day, + ) + .where( (Attendance.docstatus == 1) & (Attendance.employee == employee) & (Attendance.company == filters.company) - & (Extract('month', Attendance.attendance_date) == filters.month) - & (Extract('year', Attendance.attendance_date) == filters.year) + & (Extract("month", Attendance.attendance_date) == filters.month) + & (Extract("year", Attendance.attendance_date) == filters.year) ) ).run(as_dict=True) days = ( frappe.qb.from_(Attendance) - .select(Extract('day', Attendance.attendance_date).as_('day_of_month')) + .select(Extract("day", Attendance.attendance_date).as_("day_of_month")) .distinct() .where( (Attendance.docstatus == 1) & (Attendance.employee == employee) & (Attendance.company == filters.company) - & (Extract('month', Attendance.attendance_date) == filters.month) - & (Extract('year', Attendance.attendance_date) == filters.year) + & (Extract("month", Attendance.attendance_date) == filters.month) + & (Extract("year", Attendance.attendance_date) == filters.year) ) ).run(pluck=True) return summary[0], days -def get_attendance_status_for_detailed_view(employee: str, filters: Filters, employee_attendance: Dict, holidays: List) -> List[Dict]: +def get_attendance_status_for_detailed_view( + employee: str, filters: Filters, employee_attendance: Dict, holidays: List +) -> List[Dict]: """Returns list of shift-wise attendance status for employee - [ - {'shift': 'Morning Shift', 1: 'A', 2: 'P', 3: 'A'....}, - {'shift': 'Evening Shift', 1: 'P', 2: 'A', 3: 'P'....} - ] + [ + {'shift': 'Morning Shift', 1: 'A', 2: 'P', 3: 'A'....}, + {'shift': 'Evening Shift', 1: 'P', 2: 'A', 3: 'P'....} + ] """ total_days = get_total_days_in_month(filters) attendance_values = [] for shift, status_dict in employee_attendance.items(): - row = {'shift': shift} + row = {"shift": shift} for day in range(1, total_days + 1): status = status_dict.get(day) if status is None and holidays: status = get_holiday_status(day, holidays) - abbr = status_map.get(status, '') + abbr = status_map.get(status, "") row[day] = abbr attendance_values.append(row) @@ -435,22 +493,22 @@ def get_attendance_status_for_detailed_view(employee: str, filters: Filters, emp def get_holiday_status(day: int, holidays: List) -> str: status = None for holiday in holidays: - if day == holiday.get('day_of_month'): - if holiday.get('weekly_off'): - status = 'Weekly Off' + if day == holiday.get("day_of_month"): + if holiday.get("weekly_off"): + status = "Weekly Off" else: - status = 'Holiday' + status = "Holiday" break return status def get_leave_summary(employee: str, filters: Filters) -> Dict[str, float]: """Returns a dict of leave type and corresponding leaves taken by employee like: - {'leave_without_pay': 1.0, 'sick_leave': 2.0} + {'leave_without_pay': 1.0, 'sick_leave': 2.0} """ - Attendance = frappe.qb.DocType('Attendance') - day_case = frappe.qb.terms.Case().when(Attendance.status == 'Half Day', 0.5).else_(1) - sum_leave_days = Sum(day_case).as_('leave_days') + Attendance = frappe.qb.DocType("Attendance") + day_case = frappe.qb.terms.Case().when(Attendance.status == "Half Day", 0.5).else_(1) + sum_leave_days = Sum(day_case).as_("leave_days") leave_details = ( frappe.qb.from_(Attendance) @@ -459,10 +517,11 @@ def get_leave_summary(employee: str, filters: Filters) -> Dict[str, float]: (Attendance.employee == employee) & (Attendance.docstatus == 1) & (Attendance.company == filters.company) - & ((Attendance.leave_type.isnotnull()) | (Attendance.leave_type != '')) - & (Extract('month', Attendance.attendance_date) == filters.month) - & (Extract('year', Attendance.attendance_date) == filters.year) - ).groupby(Attendance.leave_type) + & ((Attendance.leave_type.isnotnull()) | (Attendance.leave_type != "")) + & (Extract("month", Attendance.attendance_date) == filters.month) + & (Extract("year", Attendance.attendance_date) == filters.year) + ) + .groupby(Attendance.leave_type) ).run(as_dict=True) leaves = {} @@ -475,15 +534,15 @@ def get_leave_summary(employee: str, filters: Filters) -> Dict[str, float]: def get_entry_exits_summary(employee: str, filters: Filters) -> Dict[str, float]: """Returns total late entries and total early exits for employee like: - {'total_late_entries': 5, 'total_early_exits': 2} + {'total_late_entries': 5, 'total_early_exits': 2} """ - Attendance = frappe.qb.DocType('Attendance') + Attendance = frappe.qb.DocType("Attendance") - late_entry_case = frappe.qb.terms.Case().when(Attendance.late_entry == '1', '1') - count_late_entries = Count(late_entry_case).as_('total_late_entries') + late_entry_case = frappe.qb.terms.Case().when(Attendance.late_entry == "1", "1") + count_late_entries = Count(late_entry_case).as_("total_late_entries") - early_exit_case = frappe.qb.terms.Case().when(Attendance.early_exit == '1', '1') - count_early_exits = Count(early_exit_case).as_('total_early_exits') + early_exit_case = frappe.qb.terms.Case().when(Attendance.early_exit == "1", "1") + count_early_exits = Count(early_exit_case).as_("total_early_exits") entry_exits = ( frappe.qb.from_(Attendance) @@ -492,8 +551,8 @@ def get_entry_exits_summary(employee: str, filters: Filters) -> Dict[str, float] (Attendance.docstatus == 1) & (Attendance.employee == employee) & (Attendance.company == filters.company) - & (Extract('month', Attendance.attendance_date) == filters.month) - & (Extract('year', Attendance.attendance_date) == filters.year) + & (Extract("month", Attendance.attendance_date) == filters.month) + & (Extract("year", Attendance.attendance_date) == filters.year) ) ).run(as_dict=True) @@ -503,10 +562,10 @@ def get_entry_exits_summary(employee: str, filters: Filters) -> Dict[str, float] @frappe.whitelist() def get_attendance_years() -> str: """Returns all the years for which attendance records exist""" - Attendance = frappe.qb.DocType('Attendance') + Attendance = frappe.qb.DocType("Attendance") year_list = ( frappe.qb.from_(Attendance) - .select(Extract('year', Attendance.attendance_date).as_('year')) + .select(Extract("year", Attendance.attendance_date).as_("year")) .distinct() ).run(as_dict=True) @@ -526,21 +585,21 @@ def get_chart_data(attendance_map: Dict, filters: Filters) -> Dict: leave = [] for day in days: - labels.append(day['label']) + labels.append(day["label"]) total_absent_on_day = total_leaves_on_day = total_present_on_day = 0 for employee, attendance_dict in attendance_map.items(): for shift, attendance in attendance_dict.items(): - attendance_on_day = attendance.get(day['fieldname']) + attendance_on_day = attendance.get(day["fieldname"]) - if attendance_on_day == 'Absent': + if attendance_on_day == "Absent": total_absent_on_day += 1 - elif attendance_on_day in ['Present', 'Work From Home']: + elif attendance_on_day in ["Present", "Work From Home"]: total_present_on_day += 1 - elif attendance_on_day == 'Half Day': + elif attendance_on_day == "Half Day": total_present_on_day += 0.5 total_leaves_on_day += 0.5 - elif attendance_on_day == 'On Leave': + elif attendance_on_day == "On Leave": total_leaves_on_day += 1 absent.append(total_absent_on_day) @@ -548,14 +607,14 @@ def get_chart_data(attendance_map: Dict, filters: Filters) -> Dict: leave.append(total_leaves_on_day) return { - 'data': { - 'labels': labels, - 'datasets': [ - {'name': 'Absent', 'values': absent}, - {'name': 'Present', 'values': present}, - {'name': 'Leave', 'values': leave}, - ] + "data": { + "labels": labels, + "datasets": [ + {"name": "Absent", "values": absent}, + {"name": "Present", "values": present}, + {"name": "Leave", "values": leave}, + ], }, - 'type': 'line', - 'colors': ['red', 'green', 'blue'], - } \ No newline at end of file + "type": "line", + "colors": ["red", "green", "blue"], + } diff --git a/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py b/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py index cc899eba8f..2f3cb53adb 100644 --- a/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py +++ b/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py @@ -7,10 +7,7 @@ from erpnext.hr.doctype.attendance.attendance import mark_attendance 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 make_allocation_record -from erpnext.hr.report.monthly_attendance_sheet.monthly_attendance_sheet import ( - execute, - get_total_days_in_month, -) +from erpnext.hr.report.monthly_attendance_sheet.monthly_attendance_sheet import execute from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_leave_application test_dependencies = ["Shift Type"] From 0a3cf64037e1fa96c14ea10bda8133b757dfaaf3 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Wed, 30 Mar 2022 15:24:55 +0530 Subject: [PATCH 16/31] chore: ignore formatting changes in blame --- .git-blame-ignore-revs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index e9cb6cf903..3bc22af96a 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -26,3 +26,6 @@ b147b85e6ac19a9220cd1e2958a6ebd99373283a # bulk format python code with black 494bd9ef78313436f0424b918f200dab8fc7c20b + +# bulk format python code with black +baec607ff5905b1c67531096a9cf50ec7ff00a5d \ No newline at end of file From af139193a5aebc186edccadadb400ed1a357e236 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Wed, 30 Mar 2022 23:27:49 +0530 Subject: [PATCH 17/31] test: fetching shifts in Employee Checkins --- .../employee_checkin/test_employee_checkin.py | 193 +++++++++++++++++- 1 file changed, 190 insertions(+), 3 deletions(-) diff --git a/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py b/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py index 97f76b0350..03c392746c 100644 --- a/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py +++ b/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py @@ -2,10 +2,11 @@ # See license.txt import unittest -from datetime import timedelta +from datetime import datetime, timedelta import frappe -from frappe.utils import now_datetime, nowdate +from frappe.tests.utils import FrappeTestCase +from frappe.utils import add_days, get_time, getdate, now_datetime, nowdate from erpnext.hr.doctype.employee.test_employee import make_employee from erpnext.hr.doctype.employee_checkin.employee_checkin import ( @@ -13,9 +14,15 @@ from erpnext.hr.doctype.employee_checkin.employee_checkin import ( calculate_working_hours, mark_attendance_and_link_log, ) +from erpnext.hr.doctype.leave_application.test_leave_application import get_first_sunday -class TestEmployeeCheckin(unittest.TestCase): +class TestEmployeeCheckin(FrappeTestCase): + def setUp(self): + frappe.db.delete("Shift Type") + frappe.db.delete("Shift Assignment") + frappe.db.delete("Employee Checkin") + def test_add_log_based_on_employee_field(self): employee = make_employee("test_add_log_based_on_employee_field@example.com") employee = frappe.get_doc("Employee", employee) @@ -103,6 +110,144 @@ class TestEmployeeCheckin(unittest.TestCase): ) self.assertEqual(working_hours, (4.5, logs_type_2[1].time, logs_type_2[-1].time)) + def test_fetch_shift(self): + employee = make_employee("test_employee_checkin@example.com", company="_Test Company") + + # shift setup for 8-12 + shift_type = setup_shift_type() + date = getdate() + make_shift_assignment(shift_type.name, employee, date) + + # within shift time + timestamp = datetime.combine(date, get_time("08:45:00")) + log = make_checkin(employee, timestamp) + self.assertEqual(log.shift, shift_type.name) + + # "begin checkin before shift time" = 60 mins, so should work for 7:00:00 + timestamp = datetime.combine(date, get_time("07:00:00")) + log = make_checkin(employee, timestamp) + self.assertEqual(log.shift, shift_type.name) + + # "allow checkout after shift end time" = 60 mins, so should work for 13:00:00 + timestamp = datetime.combine(date, get_time("13:00:00")) + log = make_checkin(employee, timestamp) + self.assertEqual(log.shift, shift_type.name) + + # should not fetch this shift beyond allowed time + timestamp = datetime.combine(date, get_time("13:01:00")) + log = make_checkin(employee, timestamp) + self.assertIsNone(log.shift) + + def test_shift_start_and_end_timings(self): + employee = make_employee("test_employee_checkin@example.com", company="_Test Company") + + # shift setup for 8-12 + shift_type = setup_shift_type() + date = getdate() + make_shift_assignment(shift_type.name, employee, date) + + timestamp = datetime.combine(date, get_time("08:45:00")) + log = make_checkin(employee, timestamp) + + self.assertEqual(log.shift, shift_type.name) + self.assertEqual(log.shift_start, datetime.combine(date, get_time("08:00:00"))) + self.assertEqual(log.shift_end, datetime.combine(date, get_time("12:00:00"))) + self.assertEqual(log.shift_actual_start, datetime.combine(date, get_time("07:00:00"))) + self.assertEqual(log.shift_actual_end, datetime.combine(date, get_time("13:00:00"))) + + def test_fetch_shift_based_on_default_shift(self): + employee = make_employee("test_default_shift@example.com", company="_Test Company") + default_shift = setup_shift_type( + shift_type="Default Shift", start_time="14:00:00", end_time="16:00:00" + ) + + date = getdate() + frappe.db.set_value("Employee", employee, "default_shift", default_shift.name) + + timestamp = datetime.combine(date, get_time("14:45:00")) + log = make_checkin(employee, timestamp) + + # should consider default shift + self.assertEqual(log.shift, default_shift.name) + + def test_fetch_shift_spanning_over_two_days(self): + employee = make_employee("test_employee_checkin@example.com", company="_Test Company") + shift_type = setup_shift_type(start_time="23:00:00", end_time="01:00:00") + date = getdate() + next_day = add_days(date, 1) + make_shift_assignment(shift_type.name, employee, date) + + # log falls in the first day + timestamp = datetime.combine(date, get_time("23:00:00")) + log = make_checkin(employee, timestamp) + + self.assertEqual(log.shift, shift_type.name) + self.assertEqual(log.shift_start, datetime.combine(date, get_time("23:00:00"))) + self.assertEqual(log.shift_end, datetime.combine(next_day, get_time("01:00:00"))) + self.assertEqual(log.shift_actual_start, datetime.combine(date, get_time("22:00:00"))) + self.assertEqual(log.shift_actual_end, datetime.combine(next_day, get_time("02:00:00"))) + + log.delete() + + # log falls in the second day + prev_day = add_days(date, -1) + timestamp = datetime.combine(date, get_time("01:30:00")) + log = make_checkin(employee, timestamp) + self.assertEqual(log.shift, shift_type.name) + self.assertEqual(log.shift_start, datetime.combine(prev_day, get_time("23:00:00"))) + self.assertEqual(log.shift_end, datetime.combine(date, get_time("01:00:00"))) + self.assertEqual(log.shift_actual_start, datetime.combine(prev_day, get_time("22:00:00"))) + self.assertEqual(log.shift_actual_end, datetime.combine(date, get_time("02:00:00"))) + + def test_no_shift_fetched_on_a_holiday(self): + employee = make_employee("test_shift_with_holiday@example.com", company="_Test Company") + setup_shift_type( + shift_type="Test Holiday Shift", holiday_list="Salary Slip Test Holiday List" + ) + date = getdate() + + first_sunday = get_first_sunday("Salary Slip Test Holiday List", for_date=date) + timestamp = datetime.combine(first_sunday, get_time("08:00:00")) + log = make_checkin(employee, timestamp) + + self.assertIsNone(log.shift) + + def test_consecutive_shift_assignments_overlapping_within_grace_period(self): + # test adjustment for start and end times if they are overlapping + # within "begin_check_in_before_shift_start_time" and "allow_check_out_after_shift_end_time" periods + employee = make_employee("test_shift_with_holiday@example.com", company="_Test Company") + + # 8 - 12 + shift1 = setup_shift_type() + # 12:30 - 16:30 + shift2 = setup_shift_type( + shift_type="Consecutive Shift", start_time="12:30:00", end_time="16:30:00" + ) + + # the actual start and end times (with grace) for these shifts are 7 - 13 and 11:30 - 17:30 + date = getdate() + make_shift_assignment(shift1.name, employee, date) + make_shift_assignment(shift2.name, employee, date) + + # log at 12:30 should set shift2 and actual start as 12 and not 11:30 + timestamp = datetime.combine(date, get_time("12:30:00")) + log = make_checkin(employee, timestamp) + self.assertEqual(log.shift, shift2.name) + self.assertEqual(log.shift_start, datetime.combine(date, get_time("12:30:00"))) + self.assertEqual(log.shift_actual_start, datetime.combine(date, get_time("12:00:00"))) + + # log at 12:00 should set shift1 and actual end as 12 and not 1 since the next shift's grace starts + timestamp = datetime.combine(date, get_time("12:00:00")) + log = make_checkin(employee, timestamp) + self.assertEqual(log.shift, shift1.name) + self.assertEqual(log.shift_end, datetime.combine(date, get_time("12:00:00"))) + self.assertEqual(log.shift_actual_end, datetime.combine(date, get_time("12:00:00"))) + + # log at 12:01 should set shift2 + timestamp = datetime.combine(date, get_time("12:01:00")) + log = make_checkin(employee, timestamp) + self.assertEqual(log.shift, shift2.name) + def make_n_checkins(employee, n, hours_to_reverse=1): logs = [make_checkin(employee, now_datetime() - timedelta(hours=hours_to_reverse, minutes=n + 1))] @@ -124,3 +269,45 @@ def make_checkin(employee, time=now_datetime()): } ).insert() return log + + +def setup_shift_type(**args): + args = frappe._dict(args) + shift_type = frappe.new_doc("Shift Type") + shift_type.__newname = args.shift_type or "_Test Shift" + shift_type.start_time = args.start_time or "08:00:00" + shift_type.end_time = args.end_time or "12:00:00" + shift_type.holiday_list = args.holiday_list + shift_type.enable_auto_attendance = 1 + + shift_type.determine_check_in_and_check_out = ( + args.determine_check_in_and_check_out + or "Alternating entries as IN and OUT during the same shift" + ) + shift_type.working_hours_calculation_based_on = ( + args.working_hours_calculation_based_on or "First Check-in and Last Check-out" + ) + shift_type.begin_check_in_before_shift_start_time = ( + args.begin_check_in_before_shift_start_time or 60 + ) + shift_type.allow_check_out_after_shift_end_time = args.allow_check_out_after_shift_end_time or 60 + + shift_type.save() + + return shift_type + + +def make_shift_assignment(shift_type, employee, start_date, end_date=None): + shift_assignment = frappe.get_doc( + { + "doctype": "Shift Assignment", + "shift_type": shift_type, + "company": "_Test Company", + "employee": employee, + "start_date": start_date, + "end_date": end_date, + } + ).insert() + shift_assignment.submit() + + return shift_assignment From 97547da7ee320ee504b2e28cef6cb9f347ad4664 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 31 Mar 2022 00:17:32 +0530 Subject: [PATCH 18/31] fix(test): make holiday list for shift and checkin tests --- .../employee_checkin/test_employee_checkin.py | 23 ++++++++++++++----- .../test_monthly_attendance_sheet.py | 10 +++++++- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py b/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py index 03c392746c..30f469989a 100644 --- a/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py +++ b/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py @@ -6,7 +6,15 @@ from datetime import datetime, timedelta import frappe from frappe.tests.utils import FrappeTestCase -from frappe.utils import add_days, get_time, getdate, now_datetime, nowdate +from frappe.utils import ( + add_days, + get_time, + get_year_ending, + get_year_start, + getdate, + now_datetime, + nowdate, +) from erpnext.hr.doctype.employee.test_employee import make_employee from erpnext.hr.doctype.employee_checkin.employee_checkin import ( @@ -15,6 +23,7 @@ from erpnext.hr.doctype.employee_checkin.employee_checkin import ( mark_attendance_and_link_log, ) from erpnext.hr.doctype.leave_application.test_leave_application import get_first_sunday +from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list class TestEmployeeCheckin(FrappeTestCase): @@ -200,13 +209,15 @@ class TestEmployeeCheckin(FrappeTestCase): self.assertEqual(log.shift_actual_end, datetime.combine(date, get_time("02:00:00"))) def test_no_shift_fetched_on_a_holiday(self): - employee = make_employee("test_shift_with_holiday@example.com", company="_Test Company") - setup_shift_type( - shift_type="Test Holiday Shift", holiday_list="Salary Slip Test Holiday List" - ) date = getdate() + from_date = get_year_start(date) + to_date = get_year_ending(date) + holiday_list = make_holiday_list() - first_sunday = get_first_sunday("Salary Slip Test Holiday List", for_date=date) + employee = make_employee("test_shift_with_holiday@example.com", company="_Test Company") + setup_shift_type(shift_type="Test Holiday Shift", holiday_list=holiday_list) + + first_sunday = get_first_sunday(holiday_list, for_date=date) timestamp = datetime.combine(first_sunday, get_time("08:00:00")) log = make_checkin(employee, timestamp) diff --git a/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py b/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py index 2f3cb53adb..fe4f01a909 100644 --- a/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py +++ b/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py @@ -8,7 +8,10 @@ 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 make_allocation_record from erpnext.hr.report.monthly_attendance_sheet.monthly_attendance_sheet import execute -from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_leave_application +from erpnext.payroll.doctype.salary_slip.test_salary_slip import ( + make_holiday_list, + make_leave_application, +) test_dependencies = ["Shift Type"] @@ -18,6 +21,11 @@ class TestMonthlyAttendanceSheet(FrappeTestCase): self.employee = make_employee("test_employee@example.com", company="_Test Company") frappe.db.delete("Attendance") + date = getdate() + from_date = get_year_start(date) + to_date = get_year_ending(date) + make_holiday_list(start_date=from_date, to_date=to_date) + @set_holiday_list("Salary Slip Test Holiday List", "_Test Company") def test_monthly_attendance_sheet_report(self): now = now_datetime() From e4cc0c1c87f728777c4952f40a805e72fcde131b Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 31 Mar 2022 00:21:23 +0530 Subject: [PATCH 19/31] fix: tests --- erpnext/hr/doctype/employee_checkin/test_employee_checkin.py | 2 +- .../monthly_attendance_sheet/test_monthly_attendance_sheet.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py b/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py index 30f469989a..ed7877f928 100644 --- a/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py +++ b/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py @@ -212,7 +212,7 @@ class TestEmployeeCheckin(FrappeTestCase): date = getdate() from_date = get_year_start(date) to_date = get_year_ending(date) - holiday_list = make_holiday_list() + holiday_list = make_holiday_list(from_date=from_date, to_date=to_date) employee = make_employee("test_shift_with_holiday@example.com", company="_Test Company") setup_shift_type(shift_type="Test Holiday Shift", holiday_list=holiday_list) diff --git a/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py b/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py index fe4f01a909..0c2e2cc597 100644 --- a/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py +++ b/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py @@ -24,7 +24,7 @@ class TestMonthlyAttendanceSheet(FrappeTestCase): date = getdate() from_date = get_year_start(date) to_date = get_year_ending(date) - make_holiday_list(start_date=from_date, to_date=to_date) + make_holiday_list(from_date=from_date, to_date=to_date) @set_holiday_list("Salary Slip Test Holiday List", "_Test Company") def test_monthly_attendance_sheet_report(self): From d45e2862168d0349e7e14185daf3dac2c8e4c338 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 31 Mar 2022 12:15:38 +0530 Subject: [PATCH 20/31] test: shift assignment creation --- .../employee_checkin/test_employee_checkin.py | 44 +-------- .../shift_assignment/test_shift_assignment.py | 98 +++++++++++++++++-- .../hr/doctype/shift_type/test_shift_type.py | 28 ++++++ 3 files changed, 121 insertions(+), 49 deletions(-) diff --git a/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py b/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py index ed7877f928..b0716be1b7 100644 --- a/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py +++ b/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py @@ -23,6 +23,8 @@ from erpnext.hr.doctype.employee_checkin.employee_checkin import ( mark_attendance_and_link_log, ) from erpnext.hr.doctype.leave_application.test_leave_application import get_first_sunday +from erpnext.hr.doctype.shift_assignment.test_shift_assignment import make_shift_assignment +from erpnext.hr.doctype.shift_type.test_shift_type import setup_shift_type from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list @@ -280,45 +282,3 @@ def make_checkin(employee, time=now_datetime()): } ).insert() return log - - -def setup_shift_type(**args): - args = frappe._dict(args) - shift_type = frappe.new_doc("Shift Type") - shift_type.__newname = args.shift_type or "_Test Shift" - shift_type.start_time = args.start_time or "08:00:00" - shift_type.end_time = args.end_time or "12:00:00" - shift_type.holiday_list = args.holiday_list - shift_type.enable_auto_attendance = 1 - - shift_type.determine_check_in_and_check_out = ( - args.determine_check_in_and_check_out - or "Alternating entries as IN and OUT during the same shift" - ) - shift_type.working_hours_calculation_based_on = ( - args.working_hours_calculation_based_on or "First Check-in and Last Check-out" - ) - shift_type.begin_check_in_before_shift_start_time = ( - args.begin_check_in_before_shift_start_time or 60 - ) - shift_type.allow_check_out_after_shift_end_time = args.allow_check_out_after_shift_end_time or 60 - - shift_type.save() - - return shift_type - - -def make_shift_assignment(shift_type, employee, start_date, end_date=None): - shift_assignment = frappe.get_doc( - { - "doctype": "Shift Assignment", - "shift_type": shift_type, - "company": "_Test Company", - "employee": employee, - "start_date": start_date, - "end_date": end_date, - } - ).insert() - shift_assignment.submit() - - return shift_assignment diff --git a/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py b/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py index 4a1ec293bd..8759870a20 100644 --- a/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py +++ b/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py @@ -4,16 +4,23 @@ import unittest import frappe -from frappe.utils import add_days, nowdate +from frappe.tests.utils import FrappeTestCase +from frappe.utils import add_days, getdate, nowdate + +from erpnext.hr.doctype.employee.test_employee import make_employee +from erpnext.hr.doctype.shift_assignment.shift_assignment import OverlappingShiftError +from erpnext.hr.doctype.shift_type.test_shift_type import setup_shift_type test_dependencies = ["Shift Type"] -class TestShiftAssignment(unittest.TestCase): +class TestShiftAssignment(FrappeTestCase): def setUp(self): - frappe.db.sql("delete from `tabShift Assignment`") + frappe.db.delete("Shift Assignment") + frappe.db.delete("Shift Type") def test_make_shift_assignment(self): + setup_shift_type(shift_type="Day Shift") shift_assignment = frappe.get_doc( { "doctype": "Shift Assignment", @@ -29,7 +36,7 @@ class TestShiftAssignment(unittest.TestCase): def test_overlapping_for_ongoing_shift(self): # shift should be Ongoing if Only start_date is present and status = Active - + setup_shift_type(shift_type="Day Shift") shift_assignment_1 = frappe.get_doc( { "doctype": "Shift Assignment", @@ -54,11 +61,11 @@ class TestShiftAssignment(unittest.TestCase): } ) - self.assertRaises(frappe.ValidationError, shift_assignment.save) + self.assertRaises(OverlappingShiftError, shift_assignment.save) def test_overlapping_for_fixed_period_shift(self): # shift should is for Fixed period if Only start_date and end_date both are present and status = Active - + setup_shift_type(shift_type="Day Shift") shift_assignment_1 = frappe.get_doc( { "doctype": "Shift Assignment", @@ -85,4 +92,81 @@ class TestShiftAssignment(unittest.TestCase): } ) - self.assertRaises(frappe.ValidationError, shift_assignment_3.save) + self.assertRaises(OverlappingShiftError, shift_assignment_3.save) + + def test_overlapping_for_a_fixed_period_shift_and_ongoing_shift(self): + employee = make_employee("test_shift_assignment@example.com", company="_Test Company") + + # shift setup for 8-12 + shift_type = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="12:00:00") + date = getdate() + # shift with end date + make_shift_assignment(shift_type.name, employee, date, add_days(date, 30)) + + # shift setup for 13-15 + shift_type = setup_shift_type(shift_type="Shift 2", start_time="11:00:00", end_time="15:00:00") + date = getdate() + + # shift assignment without end date + shift2 = frappe.get_doc( + { + "doctype": "Shift Assignment", + "shift_type": shift_type.name, + "company": "_Test Company", + "employee": employee, + "start_date": date, + } + ) + self.assertRaises(OverlappingShiftError, shift2.insert) + + def test_overlap_validation_for_shifts_on_same_day_with_overlapping_timeslots(self): + employee = make_employee("test_shift_assignment@example.com", company="_Test Company") + + # shift setup for 8-12 + shift_type = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="12:00:00") + date = getdate() + make_shift_assignment(shift_type.name, employee, date) + + # shift setup for 13-15 + shift_type = setup_shift_type(shift_type="Shift 2", start_time="11:00:00", end_time="15:00:00") + date = getdate() + + shift2 = frappe.get_doc( + { + "doctype": "Shift Assignment", + "shift_type": shift_type.name, + "company": "_Test Company", + "employee": employee, + "start_date": date, + } + ) + self.assertRaises(OverlappingShiftError, shift2.insert) + + def test_multiple_shift_assignments_for_same_day(self): + employee = make_employee("test_shift_assignment@example.com", company="_Test Company") + + # shift setup for 8-12 + shift_type = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="12:00:00") + date = getdate() + make_shift_assignment(shift_type.name, employee, date) + + # shift setup for 13-15 + shift_type = setup_shift_type(shift_type="Shift 2", start_time="13:00:00", end_time="15:00:00") + date = getdate() + make_shift_assignment(shift_type.name, employee, date) + + +def make_shift_assignment(shift_type, employee, start_date, end_date=None): + shift_assignment = frappe.get_doc( + { + "doctype": "Shift Assignment", + "shift_type": shift_type, + "company": "_Test Company", + "employee": employee, + "start_date": start_date, + "end_date": end_date, + } + ).insert() + shift_assignment.submit() + + return shift_assignment diff --git a/erpnext/hr/doctype/shift_type/test_shift_type.py b/erpnext/hr/doctype/shift_type/test_shift_type.py index 7d2f29cd6c..b91a7c3d7f 100644 --- a/erpnext/hr/doctype/shift_type/test_shift_type.py +++ b/erpnext/hr/doctype/shift_type/test_shift_type.py @@ -3,6 +3,34 @@ import unittest +import frappe + class TestShiftType(unittest.TestCase): pass + + +def setup_shift_type(**args): + args = frappe._dict(args) + shift_type = frappe.new_doc("Shift Type") + shift_type.__newname = args.shift_type or "_Test Shift" + shift_type.start_time = args.start_time or "08:00:00" + shift_type.end_time = args.end_time or "12:00:00" + shift_type.holiday_list = args.holiday_list + shift_type.enable_auto_attendance = 1 + + shift_type.determine_check_in_and_check_out = ( + args.determine_check_in_and_check_out + or "Alternating entries as IN and OUT during the same shift" + ) + shift_type.working_hours_calculation_based_on = ( + args.working_hours_calculation_based_on or "First Check-in and Last Check-out" + ) + shift_type.begin_check_in_before_shift_start_time = ( + args.begin_check_in_before_shift_start_time or 60 + ) + shift_type.allow_check_out_after_shift_end_time = args.allow_check_out_after_shift_end_time or 60 + + shift_type.save() + + return shift_type From 655c1dd6ab8f4d7227a142b3ad89a8bc4c3df615 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 3 Apr 2022 21:47:32 +0530 Subject: [PATCH 21/31] fix: attendance fixes - check half day attendance threshold before absent threshold to avoid half day getting marked as absent - round working hours to 2 digits for better accuracy - start and end dates for absent attendance marking --- .../employee_checkin/employee_checkin.py | 2 +- erpnext/hr/doctype/shift_type/shift_type.py | 22 +++++++++++++------ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/erpnext/hr/doctype/employee_checkin/employee_checkin.py b/erpnext/hr/doctype/employee_checkin/employee_checkin.py index 81c9a46059..662b236222 100644 --- a/erpnext/hr/doctype/employee_checkin/employee_checkin.py +++ b/erpnext/hr/doctype/employee_checkin/employee_checkin.py @@ -230,7 +230,7 @@ def calculate_working_hours(logs, check_in_out_type, working_hours_calc_type): def time_diff_in_hours(start, end): - return round((end - start).total_seconds() / 3600, 1) + return round(float((end - start).total_seconds()) / 3600, 2) def find_index_in_dict(dict_list, key, value): diff --git a/erpnext/hr/doctype/shift_type/shift_type.py b/erpnext/hr/doctype/shift_type/shift_type.py index f5689d190f..5e214cf7b7 100644 --- a/erpnext/hr/doctype/shift_type/shift_type.py +++ b/erpnext/hr/doctype/shift_type/shift_type.py @@ -97,16 +97,16 @@ class ShiftType(Document): ): early_exit = True - if ( - self.working_hours_threshold_for_absent - and total_working_hours < self.working_hours_threshold_for_absent - ): - return "Absent", total_working_hours, late_entry, early_exit, in_time, out_time 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, late_entry, early_exit, in_time, out_time + if ( + self.working_hours_threshold_for_absent + and total_working_hours < self.working_hours_threshold_for_absent + ): + return "Absent", total_working_hours, late_entry, early_exit, in_time, out_time return "Present", total_working_hours, late_entry, early_exit, in_time, out_time def mark_absent_for_dates_with_no_attendance(self, employee): @@ -116,7 +116,7 @@ class ShiftType(Document): start_date, end_date = self.get_start_and_end_dates(employee) # no shift assignment found, no need to process absent attendance records - if end_date is None: + if start_date is None: return holiday_list_name = self.holiday_list @@ -137,6 +137,10 @@ class ShiftType(Document): mark_attendance(employee, date, "Absent", self.name) def get_start_and_end_dates(self, employee): + """Returns start and end dates for checking attendance and marking absent + return: start date = max of `process_attendance_after` and DOJ + return: end date = min of shift before `last_sync_of_checkin` and Relieving Date + """ date_of_joining, relieving_date, employee_creation = frappe.db.get_value( "Employee", employee, ["date_of_joining", "relieving_date", "creation"] ) @@ -152,6 +156,8 @@ class ShiftType(Document): shift_details.actual_start if shift_details else get_datetime(self.last_sync_of_checkin) ) + # check if shift is found for 1 day before the last sync of checkin + # absentees are auto-marked 1 day after the shift to wait for any manual attendance records prev_shift = get_employee_shift(employee, last_shift_time - timedelta(days=1), True, "reverse") if prev_shift: end_date = ( @@ -159,7 +165,9 @@ class ShiftType(Document): if relieving_date else prev_shift.start_datetime.date() ) - + else: + # no shift found + return None, None return start_date, end_date def get_assigned_employee(self, from_date=None, consider_default_shift=False): From 6fffdcf0c7a4e82136620508e2e405ce8b7a336a Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 3 Apr 2022 22:37:10 +0530 Subject: [PATCH 22/31] test: Shift Type with Auto Attendance setup and working fix test setups --- .../employee_checkin/test_employee_checkin.py | 7 +- .../shift_assignment/test_shift_assignment.py | 18 +- .../hr/doctype/shift_type/test_shift_type.py | 289 ++++++++++++++++-- 3 files changed, 276 insertions(+), 38 deletions(-) diff --git a/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py b/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py index b0716be1b7..d9cf4bd13a 100644 --- a/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py +++ b/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py @@ -23,8 +23,7 @@ from erpnext.hr.doctype.employee_checkin.employee_checkin import ( mark_attendance_and_link_log, ) from erpnext.hr.doctype.leave_application.test_leave_application import get_first_sunday -from erpnext.hr.doctype.shift_assignment.test_shift_assignment import make_shift_assignment -from erpnext.hr.doctype.shift_type.test_shift_type import setup_shift_type +from erpnext.hr.doctype.shift_type.test_shift_type import make_shift_assignment, setup_shift_type from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list @@ -183,7 +182,9 @@ class TestEmployeeCheckin(FrappeTestCase): def test_fetch_shift_spanning_over_two_days(self): employee = make_employee("test_employee_checkin@example.com", company="_Test Company") - shift_type = setup_shift_type(start_time="23:00:00", end_time="01:00:00") + shift_type = setup_shift_type( + shift_type="Midnight Shift", start_time="23:00:00", end_time="01:00:00" + ) date = getdate() next_day = add_days(date, 1) make_shift_assignment(shift_type.name, employee, date) diff --git a/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py b/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py index 8759870a20..048b573cd2 100644 --- a/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py +++ b/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py @@ -9,7 +9,7 @@ from frappe.utils import add_days, getdate, nowdate from erpnext.hr.doctype.employee.test_employee import make_employee from erpnext.hr.doctype.shift_assignment.shift_assignment import OverlappingShiftError -from erpnext.hr.doctype.shift_type.test_shift_type import setup_shift_type +from erpnext.hr.doctype.shift_type.test_shift_type import make_shift_assignment, setup_shift_type test_dependencies = ["Shift Type"] @@ -154,19 +154,3 @@ class TestShiftAssignment(FrappeTestCase): shift_type = setup_shift_type(shift_type="Shift 2", start_time="13:00:00", end_time="15:00:00") date = getdate() make_shift_assignment(shift_type.name, employee, date) - - -def make_shift_assignment(shift_type, employee, start_date, end_date=None): - shift_assignment = frappe.get_doc( - { - "doctype": "Shift Assignment", - "shift_type": shift_type, - "company": "_Test Company", - "employee": employee, - "start_date": start_date, - "end_date": end_date, - } - ).insert() - shift_assignment.submit() - - return shift_assignment diff --git a/erpnext/hr/doctype/shift_type/test_shift_type.py b/erpnext/hr/doctype/shift_type/test_shift_type.py index b91a7c3d7f..13b890d2a9 100644 --- a/erpnext/hr/doctype/shift_type/test_shift_type.py +++ b/erpnext/hr/doctype/shift_type/test_shift_type.py @@ -2,35 +2,288 @@ # See license.txt import unittest +from datetime import datetime import frappe +from frappe.tests.utils import FrappeTestCase +from frappe.utils import add_days, get_time, get_year_ending, get_year_start, getdate, now_datetime + +from erpnext.hr.doctype.employee.test_employee import make_employee -class TestShiftType(unittest.TestCase): - pass +class TestShiftType(FrappeTestCase): + def setUp(self): + frappe.db.delete("Shift Type") + frappe.db.delete("Shift Assignment") + frappe.db.delete("Employee Checkin") + frappe.db.delete("Attendance") + + def test_mark_attendance(self): + from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin + + employee = make_employee("test_employee_checkin@example.com", company="_Test Company") + + shift_type = setup_shift_type() + date = getdate() + make_shift_assignment(shift_type.name, employee, date) + + timestamp = datetime.combine(date, get_time("08:00:00")) + log_in = make_checkin(employee, timestamp) + self.assertEqual(log_in.shift, shift_type.name) + + timestamp = datetime.combine(date, get_time("12:00:00")) + log_out = make_checkin(employee, timestamp) + self.assertEqual(log_out.shift, shift_type.name) + + shift_type.process_auto_attendance() + + attendance = frappe.db.get_value( + "Attendance", {"shift": shift_type.name}, ["status", "name"], as_dict=True + ) + self.assertEqual(attendance.status, "Present") + + def test_entry_and_exit_grace(self): + from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin + + employee = make_employee("test_employee_checkin@example.com", company="_Test Company") + + # doesn't mark late entry until 60 mins after shift start i.e. till 9 + # doesn't mark late entry until 60 mins before shift end i.e. 11 + shift_type = setup_shift_type( + enable_entry_grace_period=1, + enable_exit_grace_period=1, + late_entry_grace_period=60, + early_exit_grace_period=60, + ) + date = getdate() + make_shift_assignment(shift_type.name, employee, date) + + timestamp = datetime.combine(date, get_time("09:30:00")) + log_in = make_checkin(employee, timestamp) + self.assertEqual(log_in.shift, shift_type.name) + + timestamp = datetime.combine(date, get_time("10:30:00")) + log_out = make_checkin(employee, timestamp) + self.assertEqual(log_out.shift, shift_type.name) + + shift_type.process_auto_attendance() + + attendance = frappe.db.get_value( + "Attendance", + {"shift": shift_type.name}, + ["status", "name", "late_entry", "early_exit"], + as_dict=True, + ) + self.assertEqual(attendance.status, "Present") + self.assertEqual(attendance.late_entry, 1) + self.assertEqual(attendance.early_exit, 1) + + def test_working_hours_threshold_for_half_day(self): + from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin + + employee = make_employee("test_employee_checkin@example.com", company="_Test Company") + shift_type = setup_shift_type(shift_type="Half Day Test", working_hours_threshold_for_half_day=2) + date = getdate() + make_shift_assignment(shift_type.name, employee, date) + + timestamp = datetime.combine(date, get_time("08:00:00")) + log_in = make_checkin(employee, timestamp) + self.assertEqual(log_in.shift, shift_type.name) + + timestamp = datetime.combine(date, get_time("09:30:00")) + log_out = make_checkin(employee, timestamp) + self.assertEqual(log_out.shift, shift_type.name) + + shift_type.process_auto_attendance() + + attendance = frappe.db.get_value( + "Attendance", {"shift": shift_type.name}, ["status", "working_hours"], as_dict=True + ) + self.assertEqual(attendance.status, "Half Day") + self.assertEqual(attendance.working_hours, 1.5) + + def test_working_hours_threshold_for_absent(self): + from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin + + employee = make_employee("test_employee_checkin@example.com", company="_Test Company") + shift_type = setup_shift_type(shift_type="Absent Test", working_hours_threshold_for_absent=2) + date = getdate() + make_shift_assignment(shift_type.name, employee, date) + + timestamp = datetime.combine(date, get_time("08:00:00")) + log_in = make_checkin(employee, timestamp) + self.assertEqual(log_in.shift, shift_type.name) + + timestamp = datetime.combine(date, get_time("09:30:00")) + log_out = make_checkin(employee, timestamp) + self.assertEqual(log_out.shift, shift_type.name) + + shift_type.process_auto_attendance() + + attendance = frappe.db.get_value( + "Attendance", {"shift": shift_type.name}, ["status", "working_hours"], as_dict=True + ) + self.assertEqual(attendance.status, "Absent") + self.assertEqual(attendance.working_hours, 1.5) + + def test_working_hours_threshold_for_absent_and_half_day_1(self): + # considers half day over absent + from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin + + employee = make_employee("test_employee_checkin@example.com", company="_Test Company") + shift_type = setup_shift_type( + shift_type="Half Day + Absent Test", + working_hours_threshold_for_half_day=1, + working_hours_threshold_for_absent=2, + ) + date = getdate() + make_shift_assignment(shift_type.name, employee, date) + + timestamp = datetime.combine(date, get_time("08:00:00")) + log_in = make_checkin(employee, timestamp) + self.assertEqual(log_in.shift, shift_type.name) + + timestamp = datetime.combine(date, get_time("08:45:00")) + log_out = make_checkin(employee, timestamp) + self.assertEqual(log_out.shift, shift_type.name) + + shift_type.process_auto_attendance() + + attendance = frappe.db.get_value( + "Attendance", {"shift": shift_type.name}, ["status", "working_hours"], as_dict=True + ) + self.assertEqual(attendance.status, "Half Day") + self.assertEqual(attendance.working_hours, 0.75) + + def test_working_hours_threshold_for_absent_and_half_day_2(self): + # considers absent over half day + from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin + + employee = make_employee("test_employee_checkin@example.com", company="_Test Company") + shift_type = setup_shift_type( + shift_type="Half Day + Absent Test", + working_hours_threshold_for_half_day=1, + working_hours_threshold_for_absent=2, + ) + date = getdate() + make_shift_assignment(shift_type.name, employee, date) + + timestamp = datetime.combine(date, get_time("08:00:00")) + log_in = make_checkin(employee, timestamp) + self.assertEqual(log_in.shift, shift_type.name) + + timestamp = datetime.combine(date, get_time("09:30:00")) + log_out = make_checkin(employee, timestamp) + self.assertEqual(log_out.shift, shift_type.name) + + shift_type.process_auto_attendance() + + attendance = frappe.db.get_value("Attendance", {"shift": shift_type.name}, "status") + self.assertEqual(attendance, "Absent") + + def test_mark_absent_for_dates_with_no_attendance(self): + employee = make_employee("test_employee_checkin@example.com", company="_Test Company") + shift_type = setup_shift_type(shift_type="Test Absent with no Attendance") + + # absentees are auto-marked one day after to wait for any manual attendance records + date = add_days(getdate(), -1) + make_shift_assignment(shift_type.name, employee, date) + + shift_type.process_auto_attendance() + + attendance = frappe.db.get_value( + "Attendance", {"attendance_date": date, "employee": employee}, "status" + ) + self.assertEqual(attendance, "Absent") + + def test_get_start_and_end_dates(self): + date = getdate() + + doj = add_days(date, -30) + relieving_date = add_days(date, -5) + employee = make_employee( + "test_employee_dates@example.com", + company="_Test Company", + date_of_joining=doj, + relieving_date=relieving_date, + ) + shift_type = setup_shift_type( + shift_type="Test Absent with no Attendance", process_attendance_after=add_days(doj, 2) + ) + + make_shift_assignment(shift_type.name, employee, add_days(date, -25)) + + shift_type.process_auto_attendance() + + # should not mark absent before shift assignment/process attendance after date + attendance = frappe.db.get_value( + "Attendance", {"attendance_date": doj, "employee": employee}, "name" + ) + self.assertIsNone(attendance) + + # mark absent on Relieving Date + attendance = frappe.db.get_value( + "Attendance", {"attendance_date": relieving_date, "employee": employee}, "status" + ) + self.assertEquals(attendance, "Absent") + + # should not mark absent after Relieving Date + attendance = frappe.db.get_value( + "Attendance", {"attendance_date": add_days(relieving_date, 1), "employee": employee}, "name" + ) + self.assertIsNone(attendance) def setup_shift_type(**args): args = frappe._dict(args) - shift_type = frappe.new_doc("Shift Type") - shift_type.__newname = args.shift_type or "_Test Shift" - shift_type.start_time = args.start_time or "08:00:00" - shift_type.end_time = args.end_time or "12:00:00" - shift_type.holiday_list = args.holiday_list - shift_type.enable_auto_attendance = 1 + date = getdate() - shift_type.determine_check_in_and_check_out = ( - args.determine_check_in_and_check_out - or "Alternating entries as IN and OUT during the same shift" + shift_type = frappe.get_doc( + { + "doctype": "Shift Type", + "__newname": args.shift_type or "_Test Shift", + "start_time": "08:00:00", + "end_time": "12:00:00", + "enable_auto_attendance": 1, + "determine_check_in_and_check_out": "Alternating entries as IN and OUT during the same shift", + "working_hours_calculation_based_on": "First Check-in and Last Check-out", + "begin_check_in_before_shift_start_time": 60, + "allow_check_out_after_shift_end_time": 60, + "process_attendance_after": add_days(date, -2), + "last_sync_of_checkin": now_datetime(), + } ) - shift_type.working_hours_calculation_based_on = ( - args.working_hours_calculation_based_on or "First Check-in and Last Check-out" - ) - shift_type.begin_check_in_before_shift_start_time = ( - args.begin_check_in_before_shift_start_time or 60 - ) - shift_type.allow_check_out_after_shift_end_time = args.allow_check_out_after_shift_end_time or 60 + holiday_list = "Employee Checkin Test Holiday List" + if not frappe.db.exists("Holiday List", "Employee Checkin Test Holiday List"): + holiday_list = frappe.get_doc( + { + "doctype": "Holiday List", + "holiday_list_name": "Employee Checkin Test Holiday List", + "from_date": get_year_start(date), + "to_date": get_year_ending(date), + } + ).insert() + holiday_list = holiday_list.name + + shift_type.holiday_list = holiday_list + shift_type.update(args) shift_type.save() return shift_type + + +def make_shift_assignment(shift_type, employee, start_date, end_date=None): + shift_assignment = frappe.get_doc( + { + "doctype": "Shift Assignment", + "shift_type": shift_type, + "company": "_Test Company", + "employee": employee, + "start_date": start_date, + "end_date": end_date, + } + ).insert() + shift_assignment.submit() + + return shift_assignment From 58fb2f7ddef7ddf719afdc6168ea5a8a8a247812 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 3 Apr 2022 23:54:03 +0530 Subject: [PATCH 23/31] refactor: Overlapping validation for Shift Request - commonify time overlap function between request and assignment - add tests for shift request overlap --- .../shift_assignment/shift_assignment.py | 83 +++++----- .../shift_assignment/test_shift_assignment.py | 4 +- .../hr/doctype/shift_request/shift_request.py | 87 ++++++---- .../shift_request/test_shift_request.py | 153 +++++++++++++++++- 4 files changed, 241 insertions(+), 86 deletions(-) diff --git a/erpnext/hr/doctype/shift_assignment/shift_assignment.py b/erpnext/hr/doctype/shift_assignment/shift_assignment.py index fd0b4d5988..0b21c00eac 100644 --- a/erpnext/hr/doctype/shift_assignment/shift_assignment.py +++ b/erpnext/hr/doctype/shift_assignment/shift_assignment.py @@ -32,7 +32,7 @@ class ShiftAssignment(Document): overlapping_dates = self.get_overlapping_dates() if len(overlapping_dates): # if dates are overlapping, check if timings are overlapping, else allow - overlapping_timings = self.has_overlapping_timings(overlapping_dates[0].shift_type) + overlapping_timings = has_overlapping_timings(self.shift_type, overlapping_dates[0].shift_type) if overlapping_timings: self.throw_overlap_error(overlapping_dates[0]) @@ -43,9 +43,7 @@ class ShiftAssignment(Document): shift = frappe.qb.DocType("Shift Assignment") query = ( frappe.qb.from_(shift) - .select( - shift.name, shift.shift_type, shift.start_date, shift.end_date, shift.docstatus, shift.status - ) + .select(shift.name, shift.shift_type, shift.docstatus, shift.status) .where( (shift.employee == self.employee) & (shift.docstatus == 1) @@ -81,55 +79,46 @@ class ShiftAssignment(Document): return query.run(as_dict=True) - def has_overlapping_timings(self, overlapping_shift): - curr_shift = frappe.db.get_value( - "Shift Type", self.shift_type, ["start_time", "end_time"], as_dict=True - ) - overlapping_shift = frappe.db.get_value( - "Shift Type", overlapping_shift, ["start_time", "end_time"], as_dict=True - ) - - if ( - ( - curr_shift.start_time > overlapping_shift.start_time - and curr_shift.start_time < overlapping_shift.end_time - ) - or ( - curr_shift.end_time > overlapping_shift.start_time - and curr_shift.end_time < overlapping_shift.end_time - ) - or ( - curr_shift.start_time <= overlapping_shift.start_time - and curr_shift.end_time >= overlapping_shift.end_time - ) - ): - return True - return False - def throw_overlap_error(self, shift_details): shift_details = frappe._dict(shift_details) - msg = None if shift_details.docstatus == 1 and shift_details.status == "Active": - if shift_details.start_date and shift_details.end_date: - msg = _("Employee {0} already has an active Shift {1}: {2} from {3} to {4}").format( - frappe.bold(self.employee), - frappe.bold(self.shift_type), - get_link_to_form("Shift Assignment", shift_details.name), - getdate(self.start_date).strftime("%d-%m-%Y"), - getdate(self.end_date).strftime("%d-%m-%Y"), - ) - else: - msg = _("Employee {0} already has an active Shift {1}: {2} from {3}").format( - frappe.bold(self.employee), - frappe.bold(self.shift_type), - get_link_to_form("Shift Assignment", shift_details.name), - getdate(self.start_date).strftime("%d-%m-%Y"), - ) - - if msg: + msg = _( + "Employee {0} already has an active Shift {1}: {2} that overlaps within this period." + ).format( + frappe.bold(self.employee), + frappe.bold(shift_details.shift_type), + get_link_to_form("Shift Assignment", shift_details.name), + ) frappe.throw(msg, title=_("Overlapping Shifts"), exc=OverlappingShiftError) +def has_overlapping_timings(shift_1: str, shift_2: str) -> bool: + """ + Accepts two shift types and checks whether their timings are overlapping + """ + curr_shift = frappe.db.get_value("Shift Type", shift_1, ["start_time", "end_time"], as_dict=True) + overlapping_shift = frappe.db.get_value( + "Shift Type", shift_2, ["start_time", "end_time"], as_dict=True + ) + + if ( + ( + curr_shift.start_time > overlapping_shift.start_time + and curr_shift.start_time < overlapping_shift.end_time + ) + or ( + curr_shift.end_time > overlapping_shift.start_time + and curr_shift.end_time < overlapping_shift.end_time + ) + or ( + curr_shift.start_time <= overlapping_shift.start_time + and curr_shift.end_time >= overlapping_shift.end_time + ) + ): + return True + return False + + @frappe.whitelist() def get_events(start, end, filters=None): events = [] diff --git a/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py b/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py index 048b573cd2..0fe9108168 100644 --- a/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py +++ b/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py @@ -103,7 +103,7 @@ class TestShiftAssignment(FrappeTestCase): # shift with end date make_shift_assignment(shift_type.name, employee, date, add_days(date, 30)) - # shift setup for 13-15 + # shift setup for 11-15 shift_type = setup_shift_type(shift_type="Shift 2", start_time="11:00:00", end_time="15:00:00") date = getdate() @@ -127,7 +127,7 @@ class TestShiftAssignment(FrappeTestCase): date = getdate() make_shift_assignment(shift_type.name, employee, date) - # shift setup for 13-15 + # shift setup for 11-15 shift_type = setup_shift_type(shift_type="Shift 2", start_time="11:00:00", end_time="15:00:00") date = getdate() diff --git a/erpnext/hr/doctype/shift_request/shift_request.py b/erpnext/hr/doctype/shift_request/shift_request.py index 1e3e8ff646..083aa3d5dc 100644 --- a/erpnext/hr/doctype/shift_request/shift_request.py +++ b/erpnext/hr/doctype/shift_request/shift_request.py @@ -5,12 +5,14 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import formatdate, getdate +from frappe.query_builder import Criterion +from frappe.utils import formatdate, get_link_to_form, getdate +from erpnext.hr.doctype.shift_assignment.shift_assignment import has_overlapping_timings from erpnext.hr.utils import share_doc_with_approver, validate_active_employee -class OverlapError(frappe.ValidationError): +class OverlappingShiftRequestError(frappe.ValidationError): pass @@ -18,7 +20,7 @@ class ShiftRequest(Document): def validate(self): validate_active_employee(self.employee) self.validate_dates() - self.validate_shift_request_overlap_dates() + self.validate_overlapping_shift_requests() self.validate_approver() self.validate_default_shift() @@ -79,37 +81,60 @@ class ShiftRequest(Document): if self.from_date and self.to_date and (getdate(self.to_date) < getdate(self.from_date)): frappe.throw(_("To date cannot be before from date")) - def validate_shift_request_overlap_dates(self): + def validate_overlapping_shift_requests(self): + overlapping_dates = self.get_overlapping_dates() + if len(overlapping_dates): + # if dates are overlapping, check if timings are overlapping, else allow + overlapping_timings = has_overlapping_timings(self.shift_type, overlapping_dates[0].shift_type) + if overlapping_timings: + self.throw_overlap_error(overlapping_dates[0]) + + def get_overlapping_dates(self): if not self.name: self.name = "New Shift Request" - d = frappe.db.sql( - """ - select - name, shift_type, from_date, to_date - from `tabShift Request` - where employee = %(employee)s and docstatus < 2 - and ((%(from_date)s >= from_date - and %(from_date)s <= to_date) or - ( %(to_date)s >= from_date - and %(to_date)s <= to_date )) - and name != %(name)s""", - { - "employee": self.employee, - "shift_type": self.shift_type, - "from_date": self.from_date, - "to_date": self.to_date, - "name": self.name, - }, - as_dict=1, + shift = frappe.qb.DocType("Shift Request") + query = ( + frappe.qb.from_(shift) + .select(shift.name, shift.shift_type) + .where((shift.employee == self.employee) & (shift.docstatus < 2) & (shift.name != self.name)) ) - for date_overlap in d: - if date_overlap["name"]: - self.throw_overlap_error(date_overlap) + if self.to_date: + query = query.where( + Criterion.any( + [ + Criterion.any( + [ + shift.to_date.isnull(), + ((self.from_date >= shift.from_date) & (self.from_date <= shift.to_date)), + ] + ), + Criterion.any( + [ + ((self.to_date >= shift.from_date) & (self.to_date <= shift.to_date)), + shift.from_date.between(self.from_date, self.to_date), + ] + ), + ] + ) + ) + else: + query = query.where( + shift.to_date.isnull() + | ((self.from_date >= shift.from_date) & (self.from_date <= shift.to_date)) + ) - def throw_overlap_error(self, d): - msg = _("Employee {0} has already applied for {1} between {2} and {3} : ").format( - self.employee, d["shift_type"], formatdate(d["from_date"]), formatdate(d["to_date"]) - ) + """ {0}""".format(d["name"]) - frappe.throw(msg, OverlapError) + return query.run(as_dict=True) + + def throw_overlap_error(self, shift_details): + shift_details = frappe._dict(shift_details) + msg = _( + "Employee {0} has already applied for Shift {1}: {2} that overlaps within this period" + ).format( + frappe.bold(self.employee), + frappe.bold(shift_details.shift_type), + get_link_to_form("Shift Request", shift_details.name), + ) + + frappe.throw(msg, title=_("Overlapping Shift Requests"), exc=OverlappingShiftRequestError) diff --git a/erpnext/hr/doctype/shift_request/test_shift_request.py b/erpnext/hr/doctype/shift_request/test_shift_request.py index b4f5177215..c47418cfa8 100644 --- a/erpnext/hr/doctype/shift_request/test_shift_request.py +++ b/erpnext/hr/doctype/shift_request/test_shift_request.py @@ -4,23 +4,24 @@ import unittest import frappe +from frappe.tests.utils import FrappeTestCase from frappe.utils import add_days, nowdate from erpnext.hr.doctype.employee.test_employee import make_employee +from erpnext.hr.doctype.shift_request.shift_request import OverlappingShiftRequestError +from erpnext.hr.doctype.shift_type.test_shift_type import setup_shift_type test_dependencies = ["Shift Type"] -class TestShiftRequest(unittest.TestCase): +class TestShiftRequest(FrappeTestCase): def setUp(self): - for doctype in ["Shift Request", "Shift Assignment"]: - frappe.db.sql("delete from `tab{doctype}`".format(doctype=doctype)) - - def tearDown(self): - frappe.db.rollback() + for doctype in ["Shift Request", "Shift Assignment", "Shift Type"]: + frappe.db.delete(doctype) def test_make_shift_request(self): "Test creation/updation of Shift Assignment from Shift Request." + setup_shift_type(shift_type="Day Shift") department = frappe.get_value("Employee", "_T-Employee-00001", "department") set_shift_approver(department) approver = frappe.db.sql( @@ -48,6 +49,7 @@ class TestShiftRequest(unittest.TestCase): self.assertEqual(shift_assignment_docstatus, 2) def test_shift_request_approver_perms(self): + setup_shift_type(shift_type="Day Shift") employee = frappe.get_doc("Employee", "_T-Employee-00001") user = "test_approver_perm_emp@example.com" make_employee(user, "_Test Company") @@ -87,6 +89,145 @@ class TestShiftRequest(unittest.TestCase): employee.shift_request_approver = "" employee.save() + def test_overlap_for_request_without_to_date(self): + # shift should be Ongoing if Only from_date is present + user = "test_shift_request@example.com" + employee = make_employee(user, company="_Test Company", shift_request_approver=user) + setup_shift_type(shift_type="Day Shift") + + shift_request = frappe.get_doc( + { + "doctype": "Shift Request", + "shift_type": "Day Shift", + "company": "_Test Company", + "employee": employee, + "from_date": nowdate(), + "approver": user, + "status": "Approved", + } + ).submit() + + shift_request = frappe.get_doc( + { + "doctype": "Shift Request", + "shift_type": "Day Shift", + "company": "_Test Company", + "employee": employee, + "from_date": add_days(nowdate(), 2), + "approver": user, + "status": "Approved", + } + ) + + self.assertRaises(OverlappingShiftRequestError, shift_request.save) + + def test_overlap_for_request_with_from_and_to_dates(self): + user = "test_shift_request@example.com" + employee = make_employee(user, company="_Test Company", shift_request_approver=user) + setup_shift_type(shift_type="Day Shift") + + shift_request = frappe.get_doc( + { + "doctype": "Shift Request", + "shift_type": "Day Shift", + "company": "_Test Company", + "employee": employee, + "from_date": nowdate(), + "to_date": add_days(nowdate(), 30), + "approver": user, + "status": "Approved", + } + ).submit() + + shift_request = frappe.get_doc( + { + "doctype": "Shift Request", + "shift_type": "Day Shift", + "company": "_Test Company", + "employee": employee, + "from_date": add_days(nowdate(), 10), + "to_date": add_days(nowdate(), 35), + "approver": user, + "status": "Approved", + } + ) + + self.assertRaises(OverlappingShiftRequestError, shift_request.save) + + def test_overlapping_for_a_fixed_period_shift_and_ongoing_shift(self): + user = "test_shift_request@example.com" + employee = make_employee(user, company="_Test Company", shift_request_approver=user) + + # shift setup for 8-12 + shift_type = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="12:00:00") + date = nowdate() + + # shift with end date + frappe.get_doc( + { + "doctype": "Shift Request", + "shift_type": shift_type.name, + "company": "_Test Company", + "employee": employee, + "from_date": date, + "to_date": add_days(date, 30), + "approver": user, + "status": "Approved", + } + ).submit() + + # shift setup for 11-15 + shift_type = setup_shift_type(shift_type="Shift 2", start_time="11:00:00", end_time="15:00:00") + shift2 = frappe.get_doc( + { + "doctype": "Shift Request", + "shift_type": shift_type.name, + "company": "_Test Company", + "employee": employee, + "from_date": date, + "approver": user, + "status": "Approved", + } + ) + + self.assertRaises(OverlappingShiftRequestError, shift2.insert) + + def test_allow_non_overlapping_shift_requests_for_same_day(self): + user = "test_shift_request@example.com" + employee = make_employee(user, company="_Test Company", shift_request_approver=user) + + # shift setup for 8-12 + shift_type = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="12:00:00") + date = nowdate() + + # shift with end date + frappe.get_doc( + { + "doctype": "Shift Request", + "shift_type": shift_type.name, + "company": "_Test Company", + "employee": employee, + "from_date": date, + "to_date": add_days(date, 30), + "approver": user, + "status": "Approved", + } + ).submit() + + # shift setup for 13-15 + shift_type = setup_shift_type(shift_type="Shift 2", start_time="13:00:00", end_time="15:00:00") + frappe.get_doc( + { + "doctype": "Shift Request", + "shift_type": shift_type.name, + "company": "_Test Company", + "employee": employee, + "from_date": date, + "approver": user, + "status": "Approved", + } + ).submit() + def set_shift_approver(department): department_doc = frappe.get_doc("Department", department) From 7bd84f2696487fa88ef558060eba878657fc6075 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 3 Apr 2022 23:58:04 +0530 Subject: [PATCH 24/31] chore: remove unused import --- erpnext/hr/doctype/shift_request/shift_request.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/hr/doctype/shift_request/shift_request.py b/erpnext/hr/doctype/shift_request/shift_request.py index 083aa3d5dc..2bee2404aa 100644 --- a/erpnext/hr/doctype/shift_request/shift_request.py +++ b/erpnext/hr/doctype/shift_request/shift_request.py @@ -6,7 +6,7 @@ import frappe from frappe import _ from frappe.model.document import Document from frappe.query_builder import Criterion -from frappe.utils import formatdate, get_link_to_form, getdate +from frappe.utils import get_link_to_form, getdate from erpnext.hr.doctype.shift_assignment.shift_assignment import has_overlapping_timings from erpnext.hr.utils import share_doc_with_approver, validate_active_employee From 83489be7d9023f7bb06733c28f324a3482606302 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 4 Apr 2022 11:00:26 +0530 Subject: [PATCH 25/31] fix: add validation for overlapping shift attendance - skip auto attendance in case of overlapping shift attendance record - this case won't occur in case of shift assignment, since it will not allow overlapping shifts to be assigned - can happen if manual attendance records are created --- erpnext/hr/doctype/attendance/attendance.py | 88 +++++++++++++++---- .../employee_checkin/employee_checkin.py | 10 ++- 2 files changed, 77 insertions(+), 21 deletions(-) diff --git a/erpnext/hr/doctype/attendance/attendance.py b/erpnext/hr/doctype/attendance/attendance.py index a2487b31ff..bcaeae4fe5 100644 --- a/erpnext/hr/doctype/attendance/attendance.py +++ b/erpnext/hr/doctype/attendance/attendance.py @@ -8,6 +8,7 @@ from frappe.model.document import Document from frappe.query_builder import Criterion from frappe.utils import cint, cstr, formatdate, get_datetime, get_link_to_form, getdate, nowdate +from erpnext.hr.doctype.shift_assignment.shift_assignment import has_overlapping_timings from erpnext.hr.utils import get_holiday_dates_for_employee, validate_active_employee @@ -15,6 +16,10 @@ class DuplicateAttendanceError(frappe.ValidationError): pass +class OverlappingShiftAttendanceError(frappe.ValidationError): + pass + + class Attendance(Document): def validate(self): from erpnext.controllers.status_updater import validate_status @@ -23,6 +28,7 @@ class Attendance(Document): validate_active_employee(self.employee) self.validate_attendance_date() self.validate_duplicate_record() + self.validate_overlapping_shift_attendance() self.validate_employee_status() self.check_leave_record() @@ -55,6 +61,22 @@ class Attendance(Document): exc=DuplicateAttendanceError, ) + def validate_overlapping_shift_attendance(self): + attendance = get_overlapping_shift_attendance( + self.employee, self.attendance_date, self.shift, self.name + ) + + if attendance: + frappe.throw( + _("Attendance for employee {0} is already marked for an overlapping shift {1}: {2}").format( + frappe.bold(self.employee), + frappe.bold(attendance.shift), + get_link_to_form("Attendance", attendance.name), + ), + title=_("Overlapping Shift Attendance"), + exc=OverlappingShiftAttendanceError, + ) + def validate_employee_status(self): if frappe.db.get_value("Employee", self.employee, "status") == "Inactive": frappe.throw(_("Cannot mark attendance for an Inactive employee {0}").format(self.employee)) @@ -143,6 +165,29 @@ def get_duplicate_attendance_record(employee, attendance_date, shift, name=None) return query.run(as_dict=True) +def get_overlapping_shift_attendance(employee, attendance_date, shift, name=None): + attendance = frappe.qb.DocType("Attendance") + query = ( + frappe.qb.from_(attendance) + .select(attendance.name, attendance.shift) + .where( + (attendance.employee == employee) + & (attendance.docstatus < 2) + & (attendance.attendance_date == attendance_date) + & (attendance.shift != shift) + ) + ) + + if name: + query = query.where(attendance.name != name) + + overlapping_attendance = query.run(as_dict=True) + + if overlapping_attendance and has_overlapping_timings(shift, overlapping_attendance[0].shift): + return overlapping_attendance[0] + return {} + + @frappe.whitelist() def get_events(start, end, filters=None): events = [] @@ -190,25 +235,30 @@ def mark_attendance( late_entry=False, early_exit=False, ): - if not get_duplicate_attendance_record(employee, attendance_date, shift): - company = frappe.db.get_value("Employee", employee, "company") - attendance = frappe.get_doc( - { - "doctype": "Attendance", - "employee": employee, - "attendance_date": attendance_date, - "status": status, - "company": company, - "shift": shift, - "leave_type": leave_type, - "late_entry": late_entry, - "early_exit": early_exit, - } - ) - attendance.flags.ignore_validate = ignore_validate - attendance.insert() - attendance.submit() - return attendance.name + if get_duplicate_attendance_record(employee, attendance_date, shift): + return + + if get_overlapping_shift_attendance(employee, attendance_date, shift): + return + + company = frappe.db.get_value("Employee", employee, "company") + attendance = frappe.get_doc( + { + "doctype": "Attendance", + "employee": employee, + "attendance_date": attendance_date, + "status": status, + "company": company, + "shift": shift, + "leave_type": leave_type, + "late_entry": late_entry, + "early_exit": early_exit, + } + ) + attendance.flags.ignore_validate = ignore_validate + attendance.insert() + attendance.submit() + return attendance.name @frappe.whitelist() diff --git a/erpnext/hr/doctype/employee_checkin/employee_checkin.py b/erpnext/hr/doctype/employee_checkin/employee_checkin.py index 662b236222..64eb019b00 100644 --- a/erpnext/hr/doctype/employee_checkin/employee_checkin.py +++ b/erpnext/hr/doctype/employee_checkin/employee_checkin.py @@ -7,7 +7,10 @@ from frappe import _ from frappe.model.document import Document from frappe.utils import cint, get_datetime -from erpnext.hr.doctype.attendance.attendance import get_duplicate_attendance_record +from erpnext.hr.doctype.attendance.attendance import ( + get_duplicate_attendance_record, + get_overlapping_shift_attendance, +) from erpnext.hr.doctype.shift_assignment.shift_assignment import ( get_actual_start_end_datetime_of_shift, ) @@ -137,7 +140,10 @@ def mark_attendance_and_link_log( return None elif attendance_status in ("Present", "Absent", "Half Day"): employee_doc = frappe.get_doc("Employee", employee) - if not get_duplicate_attendance_record(employee, attendance_date, shift): + duplicate = get_duplicate_attendance_record(employee, attendance_date, shift) + overlapping = get_overlapping_shift_attendance(employee, attendance_date, shift) + + if not duplicate and not overlapping: doc_dict = { "doctype": "Attendance", "employee": employee, From 277bda11dda30ab28735c3ce575c06ea4eb95152 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 4 Apr 2022 11:04:24 +0530 Subject: [PATCH 26/31] test: validations for duplicate and overlapping shift attendance records --- .../hr/doctype/attendance/test_attendance.py | 109 +++++++++++++++++- 1 file changed, 104 insertions(+), 5 deletions(-) diff --git a/erpnext/hr/doctype/attendance/test_attendance.py b/erpnext/hr/doctype/attendance/test_attendance.py index 058bc93d72..ecd14c8a9b 100644 --- a/erpnext/hr/doctype/attendance/test_attendance.py +++ b/erpnext/hr/doctype/attendance/test_attendance.py @@ -6,6 +6,8 @@ from frappe.tests.utils import FrappeTestCase from frappe.utils import add_days, get_year_ending, get_year_start, getdate, now_datetime, nowdate from erpnext.hr.doctype.attendance.attendance import ( + DuplicateAttendanceError, + OverlappingShiftAttendanceError, get_month_map, get_unmarked_days, mark_attendance, @@ -23,11 +25,112 @@ class TestAttendance(FrappeTestCase): from_date = get_year_start(getdate()) to_date = get_year_ending(getdate()) self.holiday_list = make_holiday_list(from_date=from_date, to_date=to_date) + frappe.db.delete("Attendance") + + def test_duplicate_attendance(self): + employee = make_employee("test_duplicate_attendance@example.com", company="_Test Company") + date = nowdate() + + mark_attendance(employee, date, "Present") + attendance = frappe.get_doc( + { + "doctype": "Attendance", + "employee": employee, + "attendance_date": date, + "status": "Absent", + "company": "_Test Company", + } + ) + + self.assertRaises(DuplicateAttendanceError, attendance.insert) + + def test_duplicate_attendance_with_shift(self): + from erpnext.hr.doctype.shift_type.test_shift_type import setup_shift_type + + employee = make_employee("test_duplicate_attendance@example.com", company="_Test Company") + date = nowdate() + + shift_1 = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="10:00:00") + mark_attendance(employee, date, "Present", shift=shift_1.name) + + # attendance record with shift + attendance = frappe.get_doc( + { + "doctype": "Attendance", + "employee": employee, + "attendance_date": date, + "status": "Absent", + "company": "_Test Company", + "shift": shift_1.name, + } + ) + + self.assertRaises(DuplicateAttendanceError, attendance.insert) + + # attendance record without any shift + attendance = frappe.get_doc( + { + "doctype": "Attendance", + "employee": employee, + "attendance_date": date, + "status": "Absent", + "company": "_Test Company", + } + ) + + self.assertRaises(DuplicateAttendanceError, attendance.insert) + + def test_overlapping_shift_attendance_validation(self): + from erpnext.hr.doctype.shift_type.test_shift_type import setup_shift_type + + employee = make_employee("test_overlap_attendance@example.com", company="_Test Company") + date = nowdate() + + shift_1 = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="10:00:00") + shift_2 = setup_shift_type(shift_type="Shift 2", start_time="09:30:00", end_time="11:00:00") + + mark_attendance(employee, date, "Present", shift=shift_1.name) + + # attendance record with overlapping shift + attendance = frappe.get_doc( + { + "doctype": "Attendance", + "employee": employee, + "attendance_date": date, + "status": "Absent", + "company": "_Test Company", + "shift": shift_2.name, + } + ) + + self.assertRaises(OverlappingShiftAttendanceError, attendance.insert) + + def test_allow_attendance_with_different_shifts(self): + # allows attendance with 2 different non-overlapping shifts + from erpnext.hr.doctype.shift_type.test_shift_type import setup_shift_type + + employee = make_employee("test_duplicate_attendance@example.com", company="_Test Company") + date = nowdate() + + shift_1 = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="10:00:00") + shift_2 = setup_shift_type(shift_type="Shift 2", start_time="11:00:00", end_time="12:00:00") + + mark_attendance(employee, date, "Present", shift_1.name) + attendance = frappe.get_doc( + { + "doctype": "Attendance", + "employee": employee, + "attendance_date": date, + "status": "Absent", + "company": "_Test Company", + "shift": shift_2.name, + } + ).insert() def test_mark_absent(self): employee = make_employee("test_mark_absent@example.com") date = nowdate() - frappe.db.delete("Attendance", {"employee": employee, "attendance_date": date}) + attendance = mark_attendance(employee, date, "Absent") fetch_attendance = frappe.get_value( "Attendance", {"employee": employee, "attendance_date": date, "status": "Absent"} @@ -42,7 +145,6 @@ class TestAttendance(FrappeTestCase): employee = make_employee( "test_unmarked_days@example.com", date_of_joining=add_days(first_day, -1) ) - frappe.db.delete("Attendance", {"employee": employee}) frappe.db.set_value("Employee", employee, "holiday_list", self.holiday_list) first_sunday = get_first_sunday(self.holiday_list, for_date=first_day) @@ -67,8 +169,6 @@ class TestAttendance(FrappeTestCase): employee = make_employee( "test_unmarked_days@example.com", date_of_joining=add_days(first_day, -1) ) - frappe.db.delete("Attendance", {"employee": employee}) - frappe.db.set_value("Employee", employee, "holiday_list", self.holiday_list) first_sunday = get_first_sunday(self.holiday_list, for_date=first_day) @@ -95,7 +195,6 @@ class TestAttendance(FrappeTestCase): employee = make_employee( "test_unmarked_days_as_per_doj@example.com", date_of_joining=doj, relieving_date=relieving_date ) - frappe.db.delete("Attendance", {"employee": employee}) frappe.db.set_value("Employee", employee, "holiday_list", self.holiday_list) From 62cdde9a84659153611822a7e8975fb01b37f571 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 4 Apr 2022 11:05:03 +0530 Subject: [PATCH 27/31] test: skip auto attendance --- .../hr/doctype/shift_type/test_shift_type.py | 68 ++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/erpnext/hr/doctype/shift_type/test_shift_type.py b/erpnext/hr/doctype/shift_type/test_shift_type.py index 13b890d2a9..59fdb13af6 100644 --- a/erpnext/hr/doctype/shift_type/test_shift_type.py +++ b/erpnext/hr/doctype/shift_type/test_shift_type.py @@ -2,7 +2,7 @@ # See license.txt import unittest -from datetime import datetime +from datetime import datetime, timedelta import frappe from frappe.tests.utils import FrappeTestCase @@ -233,6 +233,70 @@ class TestShiftType(FrappeTestCase): ) self.assertIsNone(attendance) + def test_skip_auto_attendance_for_duplicate_record(self): + # Skip auto attendance in case of duplicate attendance record + from erpnext.hr.doctype.attendance.attendance import mark_attendance + from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin + + employee = make_employee("test_employee_checkin@example.com", company="_Test Company") + + shift_type = setup_shift_type() + date = getdate() + + # mark attendance + mark_attendance(employee, date, "Present") + make_shift_assignment(shift_type.name, employee, date) + + timestamp = datetime.combine(date, get_time("08:00:00")) + log_in = make_checkin(employee, timestamp) + self.assertEqual(log_in.shift, shift_type.name) + + timestamp = datetime.combine(date, get_time("12:00:00")) + log_out = make_checkin(employee, timestamp) + self.assertEqual(log_out.shift, shift_type.name) + + # auto attendance should skip marking + shift_type.process_auto_attendance() + + log_in.reload() + log_out.reload() + self.assertEqual(log_in.skip_auto_attendance, 1) + self.assertEqual(log_out.skip_auto_attendance, 1) + + def test_skip_auto_attendance_for_overlapping_shift(self): + # Skip auto attendance in case of overlapping shift attendance record + # this case won't occur in case of shift assignment, since it will not allow overlapping shifts to be assigned + # can happen if manual attendance records are created + from erpnext.hr.doctype.attendance.attendance import mark_attendance + from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin + + employee = make_employee("test_employee_checkin@example.com", company="_Test Company") + shift_1 = setup_shift_type(shift_type="Shift 1", start_time="08:00:00", end_time="10:00:00") + shift_2 = setup_shift_type(shift_type="Shift 2", start_time="09:30:00", end_time="11:00:00") + + date = getdate() + + # mark attendance + mark_attendance(employee, date, "Present", shift=shift_1.name) + make_shift_assignment(shift_2.name, employee, date) + + timestamp = datetime.combine(date, get_time("09:30:00")) + log_in = make_checkin(employee, timestamp) + self.assertEqual(log_in.shift, shift_2.name) + + timestamp = datetime.combine(date, get_time("11:00:00")) + log_out = make_checkin(employee, timestamp) + self.assertEqual(log_out.shift, shift_2.name) + + # auto attendance should be skipped for shift 2 + # since it is already marked for overlapping shift 1 + shift_2.process_auto_attendance() + + log_in.reload() + log_out.reload() + self.assertEqual(log_in.skip_auto_attendance, 1) + self.assertEqual(log_out.skip_auto_attendance, 1) + def setup_shift_type(**args): args = frappe._dict(args) @@ -250,7 +314,7 @@ def setup_shift_type(**args): "begin_check_in_before_shift_start_time": 60, "allow_check_out_after_shift_end_time": 60, "process_attendance_after": add_days(date, -2), - "last_sync_of_checkin": now_datetime(), + "last_sync_of_checkin": now_datetime() + timedelta(days=1), } ) From 1d4b1c42f2cbc8148d63c4bd532d4943d02c5fe6 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 4 Apr 2022 11:14:40 +0530 Subject: [PATCH 28/31] fix: skip validation for overlapping shift attendance if no shift is linked --- erpnext/hr/doctype/attendance/attendance.py | 3 +++ erpnext/hr/doctype/attendance/test_attendance.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/erpnext/hr/doctype/attendance/attendance.py b/erpnext/hr/doctype/attendance/attendance.py index bcaeae4fe5..e43d40ef56 100644 --- a/erpnext/hr/doctype/attendance/attendance.py +++ b/erpnext/hr/doctype/attendance/attendance.py @@ -166,6 +166,9 @@ def get_duplicate_attendance_record(employee, attendance_date, shift, name=None) def get_overlapping_shift_attendance(employee, attendance_date, shift, name=None): + if not shift: + return {} + attendance = frappe.qb.DocType("Attendance") query = ( frappe.qb.from_(attendance) diff --git a/erpnext/hr/doctype/attendance/test_attendance.py b/erpnext/hr/doctype/attendance/test_attendance.py index ecd14c8a9b..762d0f7567 100644 --- a/erpnext/hr/doctype/attendance/test_attendance.py +++ b/erpnext/hr/doctype/attendance/test_attendance.py @@ -116,7 +116,7 @@ class TestAttendance(FrappeTestCase): shift_2 = setup_shift_type(shift_type="Shift 2", start_time="11:00:00", end_time="12:00:00") mark_attendance(employee, date, "Present", shift_1.name) - attendance = frappe.get_doc( + frappe.get_doc( { "doctype": "Attendance", "employee": employee, From fec47632bcfaa2033600a032eab9920d3c830be6 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 4 Apr 2022 21:02:22 +0530 Subject: [PATCH 29/31] test: add holiday related shift and attendance tests --- .../employee_checkin/test_employee_checkin.py | 24 +++++++++++++-- .../hr/doctype/shift_type/test_shift_type.py | 29 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py b/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py index d9cf4bd13a..81b44f8fea 100644 --- a/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py +++ b/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py @@ -22,6 +22,7 @@ from erpnext.hr.doctype.employee_checkin.employee_checkin import ( calculate_working_hours, mark_attendance_and_link_log, ) +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 from erpnext.hr.doctype.shift_type.test_shift_type import make_shift_assignment, setup_shift_type from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list @@ -33,6 +34,10 @@ class TestEmployeeCheckin(FrappeTestCase): frappe.db.delete("Shift Assignment") frappe.db.delete("Employee Checkin") + from_date = get_year_start(getdate()) + to_date = get_year_ending(getdate()) + self.holiday_list = make_holiday_list(from_date=from_date, to_date=to_date) + def test_add_log_based_on_employee_field(self): employee = make_employee("test_add_log_based_on_employee_field@example.com") employee = frappe.get_doc("Employee", employee) @@ -211,7 +216,7 @@ class TestEmployeeCheckin(FrappeTestCase): self.assertEqual(log.shift_actual_start, datetime.combine(prev_day, get_time("22:00:00"))) self.assertEqual(log.shift_actual_end, datetime.combine(date, get_time("02:00:00"))) - def test_no_shift_fetched_on_a_holiday(self): + def test_no_shift_fetched_on_holiday_as_per_shift_holiday_list(self): date = getdate() from_date = get_year_start(date) to_date = get_year_ending(date) @@ -226,10 +231,25 @@ class TestEmployeeCheckin(FrappeTestCase): self.assertIsNone(log.shift) + @set_holiday_list("Salary Slip Test Holiday List", "_Test Company") + def test_no_shift_fetched_on_holiday_as_per_employee_holiday_list(self): + employee = make_employee("test_shift_with_holiday@example.com", company="_Test Company") + shift_type = setup_shift_type(shift_type="Test Holiday Shift") + shift_type.holiday_list = None + shift_type.save() + + date = getdate() + + first_sunday = get_first_sunday(self.holiday_list, for_date=date) + timestamp = datetime.combine(first_sunday, get_time("08:00:00")) + log = make_checkin(employee, timestamp) + + self.assertIsNone(log.shift) + def test_consecutive_shift_assignments_overlapping_within_grace_period(self): # test adjustment for start and end times if they are overlapping # within "begin_check_in_before_shift_start_time" and "allow_check_out_after_shift_end_time" periods - employee = make_employee("test_shift_with_holiday@example.com", company="_Test Company") + employee = make_employee("test_shift@example.com", company="_Test Company") # 8 - 12 shift1 = setup_shift_type() diff --git a/erpnext/hr/doctype/shift_type/test_shift_type.py b/erpnext/hr/doctype/shift_type/test_shift_type.py index 59fdb13af6..0d75292a1e 100644 --- a/erpnext/hr/doctype/shift_type/test_shift_type.py +++ b/erpnext/hr/doctype/shift_type/test_shift_type.py @@ -9,6 +9,9 @@ from frappe.tests.utils import FrappeTestCase from frappe.utils import add_days, get_time, get_year_ending, get_year_start, getdate, now_datetime 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 +from erpnext.payroll.doctype.salary_slip.test_salary_slip import make_holiday_list class TestShiftType(FrappeTestCase): @@ -18,6 +21,10 @@ class TestShiftType(FrappeTestCase): frappe.db.delete("Employee Checkin") frappe.db.delete("Attendance") + from_date = get_year_start(getdate()) + to_date = get_year_ending(getdate()) + self.holiday_list = make_holiday_list(from_date=from_date, to_date=to_date) + def test_mark_attendance(self): from erpnext.hr.doctype.employee_checkin.test_employee_checkin import make_checkin @@ -196,6 +203,28 @@ class TestShiftType(FrappeTestCase): ) self.assertEqual(attendance, "Absent") + @set_holiday_list("Salary Slip Test Holiday List", "_Test Company") + def test_skip_marking_absent_on_a_holiday(self): + employee = make_employee("test_employee_checkin@example.com", company="_Test Company") + shift_type = setup_shift_type(shift_type="Test Absent with no Attendance") + shift_type.holiday_list = None + shift_type.save() + + # should not mark any attendance if no shift assignment is created + shift_type.process_auto_attendance() + attendance = frappe.db.get_value("Attendance", {"employee": employee}, "status") + self.assertIsNone(attendance) + + first_sunday = get_first_sunday(self.holiday_list, for_date=getdate()) + make_shift_assignment(shift_type.name, employee, first_sunday) + + shift_type.process_auto_attendance() + + attendance = frappe.db.get_value( + "Attendance", {"attendance_date": first_sunday, "employee": employee}, "status" + ) + self.assertIsNone(attendance) + def test_get_start_and_end_dates(self): date = getdate() From bd077dbb3b3b305497658ce5895815ba849162ff Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 4 Apr 2022 21:26:42 +0530 Subject: [PATCH 30/31] test: add attendance sheet tests for employee filter, half days --- .../test_monthly_attendance_sheet.py | 66 +++++++++++++++++-- 1 file changed, 62 insertions(+), 4 deletions(-) diff --git a/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py b/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py index 0c2e2cc597..38a5d92239 100644 --- a/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py +++ b/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py @@ -54,7 +54,7 @@ class TestMonthlyAttendanceSheet(FrappeTestCase): present = datasets[1]["values"] leaves = datasets[2]["values"] - # ensure correct attendance is reflect on the report + # ensure correct attendance is reflected on the report self.assertEqual(self.employee, record.get("employee")) self.assertEqual(absent[0], 1) self.assertEqual(present[1], 1) @@ -111,6 +111,9 @@ class TestMonthlyAttendanceSheet(FrappeTestCase): mark_attendance( self.employee, previous_month_first + relativedelta(days=1), "Present", "Day Shift" ) + mark_attendance( + self.employee, previous_month_first + relativedelta(days=2), "Half Day" + ) # half day mark_attendance( self.employee, previous_month_first + relativedelta(days=3), "Present" @@ -131,9 +134,13 @@ class TestMonthlyAttendanceSheet(FrappeTestCase): row = report[1][0] self.assertEqual(row["employee"], self.employee) - self.assertEqual(row["total_present"], 4) - self.assertEqual(row["total_absent"], 1) - self.assertEqual(row["total_leaves"], leave_application.total_leave_days) + + # 4 present + half day absent 0.5 + self.assertEqual(row["total_present"], 4.5) + # 1 present + half day absent 0.5 + self.assertEqual(row["total_absent"], 1.5) + # leave days + half day leave 0.5 + self.assertEqual(row["total_leaves"], leave_application.total_leave_days + 0.5) self.assertEqual(row["_test_leave_type"], leave_application.total_leave_days) self.assertEqual(row["total_late_entries"], 1) @@ -177,6 +184,52 @@ class TestMonthlyAttendanceSheet(FrappeTestCase): self.assertEqual(row_without_shift[3], "L") # on leave on the 3rd day self.assertEqual(row_without_shift[4], "P") # present on the 4th day + def test_attendance_with_employee_filter(self): + now = now_datetime() + previous_month = now.month - 1 + previous_month_first = now.replace(day=1).replace(month=previous_month).date() + + company = frappe.db.get_value("Employee", self.employee, "company") + + # mark different attendance status on first 3 days of previous month + mark_attendance(self.employee, previous_month_first, "Absent") + mark_attendance(self.employee, previous_month_first + relativedelta(days=1), "Present") + mark_attendance(self.employee, previous_month_first + relativedelta(days=2), "On Leave") + + filters = frappe._dict( + {"month": previous_month, "year": now.year, "company": company, "employee": self.employee} + ) + report = execute(filters=filters) + + record = report[1][0] + datasets = report[3]["data"]["datasets"] + absent = datasets[0]["values"] + present = datasets[1]["values"] + leaves = datasets[2]["values"] + + # ensure correct attendance is reflected on the report + self.assertEqual(self.employee, record.get("employee")) + self.assertEqual(absent[0], 1) + self.assertEqual(present[1], 1) + self.assertEqual(leaves[2], 1) + + @set_holiday_list("Salary Slip Test Holiday List", "_Test Company") + def test_validations(self): + # validation error for filters without month and year + self.assertRaises(frappe.ValidationError, execute_report_with_invalid_filters) + + # execute report without attendance record + now = now_datetime() + previous_month = now.month - 1 + previous_month_first = now.replace(day=1).replace(month=previous_month).date() + + company = frappe.db.get_value("Employee", self.employee, "company") + filters = frappe._dict( + {"month": previous_month, "year": now.year, "company": company, "group_by": "Department"} + ) + report = execute(filters=filters) + self.assertEqual(report, ([], [], None, None)) + def get_leave_application(employee): now = now_datetime() @@ -190,3 +243,8 @@ def get_leave_application(employee): from_date = now.replace(day=7).replace(month=previous_month).date() to_date = now.replace(day=8).replace(month=previous_month).date() return make_leave_application(employee, from_date, to_date, "_Test Leave Type") + + +def execute_report_with_invalid_filters(): + filters = frappe._dict({"company": "_Test Company", "group_by": "Department"}) + execute(filters=filters) From 0e1528365d812316b97ab34de54ad990cac57b8a Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 4 Apr 2022 21:31:14 +0530 Subject: [PATCH 31/31] fix: sider --- .../monthly_attendance_sheet/test_monthly_attendance_sheet.py | 1 - 1 file changed, 1 deletion(-) diff --git a/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py b/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py index 38a5d92239..cde7dd3fff 100644 --- a/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py +++ b/erpnext/hr/report/monthly_attendance_sheet/test_monthly_attendance_sheet.py @@ -221,7 +221,6 @@ class TestMonthlyAttendanceSheet(FrappeTestCase): # execute report without attendance record now = now_datetime() previous_month = now.month - 1 - previous_month_first = now.replace(day=1).replace(month=previous_month).date() company = frappe.db.get_value("Employee", self.employee, "company") filters = frappe._dict(