From ed16bbfb1d3f86e9265d6afdacefb247df4aae26 Mon Sep 17 00:00:00 2001 From: Suraj Shetty Date: Wed, 23 Feb 2022 10:54:44 +0530 Subject: [PATCH 01/76] fix: Email translations (cherry picked from commit aaa84a21ba8c1749735c6510c5f311c3db505aef) --- erpnext/translations/de.csv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/translations/de.csv b/erpnext/translations/de.csv index cf73564b9e..bda55afba7 100644 --- a/erpnext/translations/de.csv +++ b/erpnext/translations/de.csv @@ -3730,7 +3730,7 @@ Earliest Age,Frühestes Alter, Edit Details,Details bearbeiten, Edit Profile,Profil bearbeiten, Either GST Transporter ID or Vehicle No is required if Mode of Transport is Road,Bei Straßentransport ist entweder die GST-Transporter-ID oder die Fahrzeug-Nr. Erforderlich, -Email,Email, +Email,E-Mail, Email Campaigns,E-Mail-Kampagnen, Employee ID is linked with another instructor,Die Mitarbeiter-ID ist mit einem anderen Ausbilder verknüpft, Employee Tax and Benefits,Mitarbeitersteuern und -leistungen, @@ -6486,7 +6486,7 @@ Select Users,Wählen Sie Benutzer aus, Send Emails At,Die E-Mails senden um, Reminder,Erinnerung, Daily Work Summary Group User,Tägliche Arbeit Zusammenfassung Gruppenbenutzer, -email,Email, +email,E-Mail, Parent Department,Elternabteilung, Leave Block List,Urlaubssperrenliste, Days for which Holidays are blocked for this department.,"Tage, an denen eine Urlaubssperre für diese Abteilung gilt.", From c75283265ba125803d8ad6287a126f639944d934 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Fri, 25 Feb 2022 14:36:29 +0530 Subject: [PATCH 02/76] fix(pos): mode of payment disappears after save (cherry picked from commit 69c34cd7ae128dde56cde10c53b479331c33d56f) --- erpnext/accounts/doctype/pos_invoice/pos_invoice.py | 1 - 1 file changed, 1 deletion(-) diff --git a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py index 5229d87017..9b3b3aa414 100644 --- a/erpnext/accounts/doctype/pos_invoice/pos_invoice.py +++ b/erpnext/accounts/doctype/pos_invoice/pos_invoice.py @@ -439,7 +439,6 @@ class POSInvoice(SalesInvoice): self.paid_amount = 0 def set_account_for_mode_of_payment(self): - self.payments = [d for d in self.payments if d.amount or d.base_amount or d.default] for pay in self.payments: if not pay.account: pay.account = get_bank_cash_account(pay.mode_of_payment, self.company).get("account") From 823979fbcc5865691872b08cc33bfd3598e8677a Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Fri, 25 Feb 2022 15:18:06 +0530 Subject: [PATCH 03/76] fix(pos): coupon code is applied even if ignore pricing rule is check (cherry picked from commit 81514516f3c7106a5b211796bb74ddad0a6add20) # Conflicts: # erpnext/selling/page/point_of_sale/pos_payment.js --- erpnext/public/js/controllers/transaction.js | 18 +++++++++++------- .../selling/page/point_of_sale/pos_payment.js | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/erpnext/public/js/controllers/transaction.js b/erpnext/public/js/controllers/transaction.js index 136e1edb6b..840754eb73 100644 --- a/erpnext/public/js/controllers/transaction.js +++ b/erpnext/public/js/controllers/transaction.js @@ -2283,13 +2283,17 @@ erpnext.TransactionController = class TransactionController extends erpnext.taxe } coupon_code() { - var me = this; - frappe.run_serially([ - () => this.frm.doc.ignore_pricing_rule=1, - () => me.ignore_pricing_rule(), - () => this.frm.doc.ignore_pricing_rule=0, - () => me.apply_pricing_rule() - ]); + if (this.frm.doc.coupon_code || this.frm._last_coupon_code) { + // reset pricing rules if coupon code is set or is unset + const _ignore_pricing_rule = this.frm.doc.ignore_pricing_rule; + return frappe.run_serially([ + () => this.frm.doc.ignore_pricing_rule=1, + () => this.frm.trigger('ignore_pricing_rule'), + () => this.frm.doc.ignore_pricing_rule=_ignore_pricing_rule, + () => this.frm.trigger('apply_pricing_rule'), + () => this.frm._last_coupon_code = this.frm.doc.coupon_code + ]); + } } }; diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index 9650bc88a4..43cc14b008 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -170,6 +170,7 @@ erpnext.PointOfSale.Payment = class { }); frappe.ui.form.on('POS Invoice', 'coupon_code', (frm) => { +<<<<<<< HEAD if (!frm.doc.ignore_pricing_rule) { if (frm.doc.coupon_code) { frappe.run_serially([ @@ -189,6 +190,22 @@ erpnext.PointOfSale.Payment = class { () => this.update_totals_section(frm.doc) ]); } +======= + if (!frm.doc.ignore_pricing_rule && frm.doc.coupon_code) { + frappe.run_serially([ + () => frm.doc.ignore_pricing_rule=1, + () => frm.trigger('ignore_pricing_rule'), + () => frm.doc.ignore_pricing_rule=0, + () => frm.trigger('apply_pricing_rule'), + () => frm.save(), + () => this.update_totals_section(frm.doc) + ]); + } else if (frm.doc.ignore_pricing_rule && frm.doc.coupon_code) { + frappe.show_alert({ + message: __("Ignore Pricing Rule is enabled. Cannot apply coupon code."), + indicator: "orange" + }); +>>>>>>> 81514516f3 (fix(pos): coupon code is applied even if ignore pricing rule is check) } }); From d4767b541bcb7451ad202ecab083401941685424 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 28 Feb 2022 11:36:47 +0530 Subject: [PATCH 04/76] fix: merge conflict --- .../selling/page/point_of_sale/pos_payment.js | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/erpnext/selling/page/point_of_sale/pos_payment.js b/erpnext/selling/page/point_of_sale/pos_payment.js index 43cc14b008..1e9f6d7d92 100644 --- a/erpnext/selling/page/point_of_sale/pos_payment.js +++ b/erpnext/selling/page/point_of_sale/pos_payment.js @@ -170,27 +170,6 @@ erpnext.PointOfSale.Payment = class { }); frappe.ui.form.on('POS Invoice', 'coupon_code', (frm) => { -<<<<<<< HEAD - if (!frm.doc.ignore_pricing_rule) { - if (frm.doc.coupon_code) { - frappe.run_serially([ - () => frm.doc.ignore_pricing_rule=1, - () => frm.trigger('ignore_pricing_rule'), - () => frm.doc.ignore_pricing_rule=0, - () => frm.trigger('apply_pricing_rule'), - () => frm.save(), - () => this.update_totals_section(frm.doc) - ]); - } else { - frappe.run_serially([ - () => frm.doc.ignore_pricing_rule=1, - () => frm.trigger('ignore_pricing_rule'), - () => frm.doc.ignore_pricing_rule=0, - () => frm.save(), - () => this.update_totals_section(frm.doc) - ]); - } -======= if (!frm.doc.ignore_pricing_rule && frm.doc.coupon_code) { frappe.run_serially([ () => frm.doc.ignore_pricing_rule=1, @@ -205,7 +184,6 @@ erpnext.PointOfSale.Payment = class { message: __("Ignore Pricing Rule is enabled. Cannot apply coupon code."), indicator: "orange" }); ->>>>>>> 81514516f3 (fix(pos): coupon code is applied even if ignore pricing rule is check) } }); From bda9d09125e543a41dbf6f38622e3aa3085a1a54 Mon Sep 17 00:00:00 2001 From: Rohit Waghchaure Date: Mon, 28 Feb 2022 21:57:03 +0530 Subject: [PATCH 05/76] bumped to version 14.0.0-beta.2 --- erpnext/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/__init__.py b/erpnext/__init__.py index bef6661254..fc0b6df13a 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -2,7 +2,7 @@ import inspect import frappe -__version__ = '14.0.0-dev' +__version__ = '14.0.0-beta.2' def get_default_company(user=None): '''Get default company for user''' From 3711119a665fa924c48ec98de2984e5c0a411823 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 17 Feb 2022 18:32:48 +0530 Subject: [PATCH 06/76] 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 07/76] 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 08/76] 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 09/76] 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 10/76] 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 11/76] 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 12/76] 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 13/76] 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 14/76] 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 15/76] 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 16/76] 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 17/76] 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 18/76] 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 19/76] 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 20/76] 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 21/76] 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 eaeadbc422293b08eeed32f2e1c6183f63d17c64 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 30 Mar 2022 16:18:26 +0530 Subject: [PATCH 22/76] feat: additional filters in payment terms status report --- .../payment_terms_status_for_sales_order.js | 68 ++++++++++++++----- .../payment_terms_status_for_sales_order.py | 49 +++++++++++-- 2 files changed, 94 insertions(+), 23 deletions(-) diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js index 0e36b3fe3d..019bf45f06 100644 --- a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js +++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js @@ -27,28 +27,64 @@ function get_filters() { "default": frappe.datetime.get_today() }, { - "fieldname":"sales_order", - "label": __("Sales Order"), - "fieldtype": "MultiSelectList", + "fieldname":"customer_group", + "label": __("Customer Group"), + "fieldtype": "Link", "width": 100, - "options": "Sales Order", - "get_data": function(txt) { - return frappe.db.get_link_options("Sales Order", txt, this.filters()); - }, - "filters": () => { + "options": "Customer Group", + "get_query": () => { return { - docstatus: 1, - payment_terms_template: ['not in', ['']], - company: frappe.query_report.get_filter_value("company"), - transaction_date: ['between', [frappe.query_report.get_filter_value("period_start_date"), frappe.query_report.get_filter_value("period_end_date")]] + filters: { 'is_group': 0 } } - }, - on_change: function(){ - frappe.query_report.refresh(); + } + + }, + { + "fieldname":"customer", + "label": __("Customer"), + "fieldtype": "Link", + "width": 100, + "options": "Customer", + "get_query": () => { + filters = { + 'disabled': 0 + } + if(frappe.query_report.get_filter_value("customer_group") != "") { + filters['customer_group'] = frappe.query_report.get_filter_value("customer_group"); + } + return { 'filters': filters }; + } + }, + { + "fieldname":"item_group", + "label": __("Item Group"), + "fieldtype": "Link", + "width": 100, + "options": "Item Group", + "get_query": () => { + return { + filters: { 'is_group': 0 } + } + } + + }, + { + "fieldname":"item", + "label": __("Item"), + "fieldtype": "Link", + "width": 100, + "options": "Item", + "get_query": () => { + filters = { + 'disabled': 0 + } + if(frappe.query_report.get_filter_value("item_group") != "") { + filters['item_group'] = frappe.query_report.get_filter_value("item_group"); + } + return { 'filters': filters }; } } ] - return filters; } diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py index 7f797f67ee..5b9550019f 100644 --- a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py +++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py @@ -3,7 +3,7 @@ import frappe from frappe import _, qb, query_builder -from frappe.query_builder import functions +from frappe.query_builder import Criterion, functions def get_columns(): @@ -14,6 +14,12 @@ def get_columns(): "fieldtype": "Link", "options": "Sales Order", }, + { + "label": _("Customer"), + "fieldname": "customer", + "fieldtype": "Link", + "options": "Customer", + }, { "label": _("Posting Date"), "fieldname": "submitted", @@ -79,11 +85,29 @@ def get_conditions(filters): conditions.start_date = filters.period_start_date or frappe.utils.add_months( conditions.end_date, -1 ) - conditions.sales_order = filters.sales_order or [] return conditions +def build_filter_criterions(filters): + filters = frappe._dict(filters) if filters else frappe._dict({}) + qb_criterions = [] + + if filters.customer_group: + qb_criterions.append(qb.DocType("Customer").customer_group == filters.customer_group) + + if filters.customer: + qb_criterions.append(qb.DocType("Customer").name == filters.customer) + + if filters.item_group: + qb_criterions.append(qb.DocType("Item").item_group == filters.item_group) + + if filters.item: + qb_criterions.append(qb.DocType("Item").name == filters.item) + + return qb_criterions + + def get_so_with_invoices(filters): """ Get Sales Order with payment terms template with their associated Invoices @@ -92,16 +116,29 @@ def get_so_with_invoices(filters): so = qb.DocType("Sales Order") ps = qb.DocType("Payment Schedule") + cust = qb.DocType("Customer") + item = qb.DocType("Item") + soi = qb.DocType("Sales Order Item") + + conditions = get_conditions(filters) + filter_criterions = build_filter_criterions(filters) + datediff = query_builder.CustomFunction("DATEDIFF", ["cur_date", "due_date"]) ifelse = query_builder.CustomFunction("IF", ["condition", "then", "else"]) - conditions = get_conditions(filters) query_so = ( - qb.from_(so) + qb.from_(cust) + .join(so) + .on(so.customer == cust.name) + .join(soi) + .on(soi.parent == so.name) + .join(item) + .on(item.item_code == soi.item_code) .join(ps) .on(ps.parent == so.name) .select( so.name, + so.customer, so.transaction_date.as_("submitted"), ifelse(datediff(ps.due_date, functions.CurDate()) < 0, "Overdue", "Unpaid").as_("status"), ps.payment_term, @@ -117,12 +154,10 @@ def get_so_with_invoices(filters): & (so.company == conditions.company) & (so.transaction_date[conditions.start_date : conditions.end_date]) ) + .where(Criterion.all(filter_criterions)) .orderby(so.name, so.transaction_date, ps.due_date) ) - if conditions.sales_order != []: - query_so = query_so.where(so.name.isin(conditions.sales_order)) - sorders = query_so.run(as_dict=True) invoices = [] From 7558f1b07879da066676f503c0ecb11c51b8e0bc Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Wed, 30 Mar 2022 18:49:40 +0530 Subject: [PATCH 23/76] refactor: adding new filters and column to test cases --- .../test_payment_terms_status_for_sales_order.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py b/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py index 89940a6e87..0b46e949b4 100644 --- a/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py +++ b/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py @@ -48,9 +48,9 @@ class TestPaymentTermsStatusForSalesOrder(FrappeTestCase): template.insert() self.template = template - def test_payment_terms_status(self): + def test_01_payment_terms_status(self): self.create_payment_terms_template() - item = create_item(item_code="_Test Excavator", is_stock_item=0) + item = create_item(item_code="_Test Excavator 1", is_stock_item=0) so = make_sales_order( transaction_date="2021-06-15", delivery_date=add_days("2021-06-15", -30), @@ -78,13 +78,14 @@ class TestPaymentTermsStatusForSalesOrder(FrappeTestCase): "company": "_Test Company", "period_start_date": "2021-06-01", "period_end_date": "2021-06-30", - "sales_order": [so.name], + "item": item.item_code, } ) expected_value = [ { "name": so.name, + "customer": so.customer, "submitted": datetime.date(2021, 6, 15), "status": "Completed", "payment_term": None, @@ -98,6 +99,7 @@ class TestPaymentTermsStatusForSalesOrder(FrappeTestCase): }, { "name": so.name, + "customer": so.customer, "submitted": datetime.date(2021, 6, 15), "status": "Partly Paid", "payment_term": None, @@ -132,11 +134,11 @@ class TestPaymentTermsStatusForSalesOrder(FrappeTestCase): ) doc.insert() - def test_alternate_currency(self): + def test_02_alternate_currency(self): transaction_date = "2021-06-15" self.create_payment_terms_template() self.create_exchange_rate(transaction_date) - item = create_item(item_code="_Test Excavator", is_stock_item=0) + item = create_item(item_code="_Test Excavator 2", is_stock_item=0) so = make_sales_order( transaction_date=transaction_date, currency="USD", @@ -166,7 +168,7 @@ class TestPaymentTermsStatusForSalesOrder(FrappeTestCase): "company": "_Test Company", "period_start_date": "2021-06-01", "period_end_date": "2021-06-30", - "sales_order": [so.name], + "item": item.item_code, } ) @@ -174,6 +176,7 @@ class TestPaymentTermsStatusForSalesOrder(FrappeTestCase): expected_value = [ { "name": so.name, + "customer": so.customer, "submitted": datetime.date(2021, 6, 15), "status": "Completed", "payment_term": None, @@ -187,6 +190,7 @@ class TestPaymentTermsStatusForSalesOrder(FrappeTestCase): }, { "name": so.name, + "customer": so.customer, "submitted": datetime.date(2021, 6, 15), "status": "Partly Paid", "payment_term": None, From af139193a5aebc186edccadadb400ed1a357e236 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Wed, 30 Mar 2022 23:27:49 +0530 Subject: [PATCH 24/76] 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 25/76] 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 26/76] 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 27/76] 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 28/76] 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 29/76] 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 30/76] 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 31/76] 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 32/76] 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 33/76] 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 34/76] 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 35/76] 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 18a3c5d536647a453e0a6fc2c39e32d18dcced7f Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 4 Apr 2022 12:32:33 +0530 Subject: [PATCH 36/76] fix: Ignore user perm for party account company --- erpnext/accounts/doctype/party_account/party_account.json | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/party_account/party_account.json b/erpnext/accounts/doctype/party_account/party_account.json index c9f15a6a47..69330577ab 100644 --- a/erpnext/accounts/doctype/party_account/party_account.json +++ b/erpnext/accounts/doctype/party_account/party_account.json @@ -3,6 +3,7 @@ "creation": "2014-08-29 16:02:39.740505", "doctype": "DocType", "editable_grid": 1, + "engine": "InnoDB", "field_order": [ "company", "account" @@ -11,6 +12,7 @@ { "fieldname": "company", "fieldtype": "Link", + "ignore_user_permissions": 1, "in_list_view": 1, "label": "Company", "options": "Company", @@ -27,7 +29,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2021-04-07 18:13:08.833822", + "modified": "2022-04-04 12:31:02.994197", "modified_by": "Administrator", "module": "Accounts", "name": "Party Account", @@ -35,5 +37,6 @@ "permissions": [], "quick_entry": 1, "sort_field": "modified", - "sort_order": "DESC" + "sort_order": "DESC", + "states": [] } \ No newline at end of file From 194605823e6ab0db8509b08095067edfc768d093 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 4 Apr 2022 13:25:35 +0530 Subject: [PATCH 37/76] fix: Issues on loan repayment --- .../loan_management/doctype/loan_repayment/loan_repayment.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py index 8cffe88f32..460a514baa 100644 --- a/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py +++ b/erpnext/loan_management/doctype/loan_repayment/loan_repayment.py @@ -584,9 +584,10 @@ def regenerate_repayment_schedule(loan, cancel=0): balance_amount / len(loan_doc.get("repayment_schedule")) - accrued_entries ) else: - if not cancel: + repayment_period = loan_doc.repayment_periods - accrued_entries + if not cancel and repayment_period > 0: monthly_repayment_amount = get_monthly_repayment_amount( - balance_amount, loan_doc.rate_of_interest, loan_doc.repayment_periods - accrued_entries + balance_amount, loan_doc.rate_of_interest, repayment_period ) else: monthly_repayment_amount = last_repayment_amount From e324d668d3b33eb6eaee955295ffceb632e50860 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 4 Apr 2022 14:52:19 +0530 Subject: [PATCH 38/76] refactor: item filters are linked with group filters --- .../payment_terms_status_for_sales_order.js | 37 ++++++-------- .../payment_terms_status_for_sales_order.py | 49 +++++++++++++++++++ 2 files changed, 63 insertions(+), 23 deletions(-) diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js index 019bf45f06..c068ae3b5a 100644 --- a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js +++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.js @@ -32,12 +32,6 @@ function get_filters() { "fieldtype": "Link", "width": 100, "options": "Customer Group", - "get_query": () => { - return { - filters: { 'is_group': 0 } - } - } - }, { "fieldname":"customer", @@ -46,13 +40,14 @@ function get_filters() { "width": 100, "options": "Customer", "get_query": () => { - filters = { - 'disabled': 0 + var customer_group = frappe.query_report.get_filter_value('customer_group'); + return{ + "query": "erpnext.selling.report.payment_terms_status_for_sales_order.payment_terms_status_for_sales_order.get_customers_or_items", + "filters": [ + ['Customer', 'disabled', '=', '0'], + ['Customer Group','name', '=', customer_group] + ] } - if(frappe.query_report.get_filter_value("customer_group") != "") { - filters['customer_group'] = frappe.query_report.get_filter_value("customer_group"); - } - return { 'filters': filters }; } }, { @@ -61,11 +56,6 @@ function get_filters() { "fieldtype": "Link", "width": 100, "options": "Item Group", - "get_query": () => { - return { - filters: { 'is_group': 0 } - } - } }, { @@ -75,13 +65,14 @@ function get_filters() { "width": 100, "options": "Item", "get_query": () => { - filters = { - 'disabled': 0 + var item_group = frappe.query_report.get_filter_value('item_group'); + return{ + "query": "erpnext.selling.report.payment_terms_status_for_sales_order.payment_terms_status_for_sales_order.get_customers_or_items", + "filters": [ + ['Item', 'disabled', '=', '0'], + ['Item Group','name', '=', item_group] + ] } - if(frappe.query_report.get_filter_value("item_group") != "") { - filters['item_group'] = frappe.query_report.get_filter_value("item_group"); - } - return { 'filters': filters }; } } ] diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py index 5b9550019f..befbf40e28 100644 --- a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py +++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py @@ -73,6 +73,55 @@ def get_columns(): return columns +def get_descendants_of(doctype, group_name): + group_doc = qb.DocType(doctype) + # get lft and rgt of group node + lft, rgt = ( + qb.from_(group_doc).select(group_doc.lft, group_doc.rgt).where(group_doc.name == group_name) + ).run()[0] + + # get all children of group node + query = ( + qb.from_(group_doc).select(group_doc.name).where((group_doc.lft >= lft) & (group_doc.rgt <= rgt)) + ) + + child_nodes = [] + for x in query.run(): + child_nodes.append(x[0]) + + return child_nodes + + +@frappe.whitelist() +@frappe.validate_and_sanitize_search_inputs +def get_customers_or_items(doctype, txt, searchfield, start, page_len, filters): + filter_list = [] + if isinstance(filters, list): + for item in filters: + if item[0] == doctype: + filter_list.append(item) + elif item[0] == "Customer Group": + if item[3] != "": + filter_list.append( + [doctype, "customer_group", "in", get_descendants_of("Customer Group", item[3])] + ) + elif item[0] == "Item Group": + if item[3] != "": + filter_list.append([doctype, "item_group", "in", get_descendants_of("Item Group", item[3])]) + + if searchfield and txt: + filter_list.append([doctype, searchfield, "like", "%%%s%%" % txt]) + + return frappe.desk.reportview.execute( + doctype, + filters=filter_list, + fields=["name", "customer_group"] if doctype == "Customer" else ["name", "item_group"], + limit_start=start, + limit_page_length=page_len, + as_list=True, + ) + + def get_conditions(filters): """ Convert filter options to conditions used in query From b2ed9fd3fe62a1f714150f75bc08d0875f3e46c1 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Mon, 4 Apr 2022 16:21:46 +0530 Subject: [PATCH 39/76] refactor: use group fields from Sales Order and Sales Order Items --- .../payment_terms_status_for_sales_order.py | 24 ++++++++++--------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py index befbf40e28..cb22fb6a80 100644 --- a/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py +++ b/erpnext/selling/report/payment_terms_status_for_sales_order/payment_terms_status_for_sales_order.py @@ -143,16 +143,24 @@ def build_filter_criterions(filters): qb_criterions = [] if filters.customer_group: - qb_criterions.append(qb.DocType("Customer").customer_group == filters.customer_group) + qb_criterions.append( + qb.DocType("Sales Order").customer_group.isin( + get_descendants_of("Customer Group", filters.customer_group) + ) + ) if filters.customer: - qb_criterions.append(qb.DocType("Customer").name == filters.customer) + qb_criterions.append(qb.DocType("Sales Order").customer == filters.customer) if filters.item_group: - qb_criterions.append(qb.DocType("Item").item_group == filters.item_group) + qb_criterions.append( + qb.DocType("Sales Order Item").item_group.isin( + get_descendants_of("Item Group", filters.item_group) + ) + ) if filters.item: - qb_criterions.append(qb.DocType("Item").name == filters.item) + qb_criterions.append(qb.DocType("Sales Order Item").item_code == filters.item) return qb_criterions @@ -165,8 +173,6 @@ def get_so_with_invoices(filters): so = qb.DocType("Sales Order") ps = qb.DocType("Payment Schedule") - cust = qb.DocType("Customer") - item = qb.DocType("Item") soi = qb.DocType("Sales Order Item") conditions = get_conditions(filters) @@ -176,13 +182,9 @@ def get_so_with_invoices(filters): ifelse = query_builder.CustomFunction("IF", ["condition", "then", "else"]) query_so = ( - qb.from_(cust) - .join(so) - .on(so.customer == cust.name) + qb.from_(so) .join(soi) .on(soi.parent == so.name) - .join(item) - .on(item.item_code == soi.item_code) .join(ps) .on(ps.parent == so.name) .select( From fec47632bcfaa2033600a032eab9920d3c830be6 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 4 Apr 2022 21:02:22 +0530 Subject: [PATCH 40/76] 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 41/76] 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 42/76] 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( From 16bfb930f810e3e44e71192f3676d9636b100a79 Mon Sep 17 00:00:00 2001 From: ruthra kumar Date: Tue, 5 Apr 2022 10:23:19 +0530 Subject: [PATCH 43/76] test: added test cases for group filters --- ...st_payment_terms_status_for_sales_order.py | 136 +++++++++++++++++- 1 file changed, 135 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py b/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py index 0b46e949b4..9d542f5079 100644 --- a/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py +++ b/erpnext/selling/report/payment_terms_status_for_sales_order/test_payment_terms_status_for_sales_order.py @@ -11,10 +11,13 @@ from erpnext.selling.report.payment_terms_status_for_sales_order.payment_terms_s ) from erpnext.stock.doctype.item.test_item import create_item -test_dependencies = ["Sales Order", "Item", "Sales Invoice", "Payment Terms Template"] +test_dependencies = ["Sales Order", "Item", "Sales Invoice", "Payment Terms Template", "Customer"] class TestPaymentTermsStatusForSalesOrder(FrappeTestCase): + def tearDown(self): + frappe.db.rollback() + def create_payment_terms_template(self): # create template for 50-50 payments template = None @@ -204,3 +207,134 @@ class TestPaymentTermsStatusForSalesOrder(FrappeTestCase): }, ] self.assertEqual(data, expected_value) + + def test_03_group_filters(self): + transaction_date = "2021-06-15" + self.create_payment_terms_template() + item1 = create_item(item_code="_Test Excavator 1", is_stock_item=0) + item1.item_group = "Products" + item1.save() + + so1 = make_sales_order( + transaction_date=transaction_date, + delivery_date=add_days(transaction_date, -30), + item=item1.item_code, + qty=1, + rate=1000000, + do_not_save=True, + ) + so1.po_no = "" + so1.taxes_and_charges = "" + so1.taxes = "" + so1.payment_terms_template = self.template.name + so1.save() + so1.submit() + + item2 = create_item(item_code="_Test Steel", is_stock_item=0) + item2.item_group = "Raw Material" + item2.save() + + so2 = make_sales_order( + customer="_Test Customer 1", + transaction_date=transaction_date, + delivery_date=add_days(transaction_date, -30), + item=item2.item_code, + qty=100, + rate=1000, + do_not_save=True, + ) + so2.po_no = "" + so2.taxes_and_charges = "" + so2.taxes = "" + so2.payment_terms_template = self.template.name + so2.save() + so2.submit() + + base_filters = { + "company": "_Test Company", + "period_start_date": "2021-06-01", + "period_end_date": "2021-06-30", + } + + expected_value_so1 = [ + { + "name": so1.name, + "customer": so1.customer, + "submitted": datetime.date(2021, 6, 15), + "status": "Overdue", + "payment_term": None, + "description": "_Test 50-50", + "due_date": datetime.date(2021, 6, 30), + "invoice_portion": 50.0, + "currency": "INR", + "base_payment_amount": 500000.0, + "paid_amount": 0.0, + "invoices": "", + }, + { + "name": so1.name, + "customer": so1.customer, + "submitted": datetime.date(2021, 6, 15), + "status": "Overdue", + "payment_term": None, + "description": "_Test 50-50", + "due_date": datetime.date(2021, 7, 15), + "invoice_portion": 50.0, + "currency": "INR", + "base_payment_amount": 500000.0, + "paid_amount": 0.0, + "invoices": "", + }, + ] + + expected_value_so2 = [ + { + "name": so2.name, + "customer": so2.customer, + "submitted": datetime.date(2021, 6, 15), + "status": "Overdue", + "payment_term": None, + "description": "_Test 50-50", + "due_date": datetime.date(2021, 6, 30), + "invoice_portion": 50.0, + "currency": "INR", + "base_payment_amount": 50000.0, + "paid_amount": 0.0, + "invoices": "", + }, + { + "name": so2.name, + "customer": so2.customer, + "submitted": datetime.date(2021, 6, 15), + "status": "Overdue", + "payment_term": None, + "description": "_Test 50-50", + "due_date": datetime.date(2021, 7, 15), + "invoice_portion": 50.0, + "currency": "INR", + "base_payment_amount": 50000.0, + "paid_amount": 0.0, + "invoices": "", + }, + ] + + group_filters = [ + {"customer_group": "All Customer Groups"}, + {"item_group": "All Item Groups"}, + {"item_group": "Products"}, + {"item_group": "Raw Material"}, + ] + + expected_values_for_group_filters = [ + expected_value_so1 + expected_value_so2, + expected_value_so1 + expected_value_so2, + expected_value_so1, + expected_value_so2, + ] + + for idx, g in enumerate(group_filters, 0): + # build filter + filters = frappe._dict({}).update(base_filters).update(g) + with self.subTest(filters=filters): + columns, data, message, chart = execute(filters) + self.assertEqual(data, expected_values_for_group_filters[idx]) From dec0c1b5bb06455ac5641e9cdc2846374a018259 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Wed, 6 Apr 2022 10:21:27 +0530 Subject: [PATCH 44/76] test: Ignore parent company account creation --- erpnext/setup/doctype/company/test_records.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/setup/doctype/company/test_records.json b/erpnext/setup/doctype/company/test_records.json index 89be607d04..19b6ef27ac 100644 --- a/erpnext/setup/doctype/company/test_records.json +++ b/erpnext/setup/doctype/company/test_records.json @@ -8,7 +8,8 @@ "domain": "Manufacturing", "chart_of_accounts": "Standard", "default_holiday_list": "_Test Holiday List", - "enable_perpetual_inventory": 0 + "enable_perpetual_inventory": 0, + "allow_account_creation_against_child_company": 1 }, { "abbr": "_TC1", From f20890325043ed1c1caf462e5025ecca6a0990bd Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Wed, 6 Apr 2022 10:56:47 +0530 Subject: [PATCH 45/76] feat: Scheduling Multiple shifts and Auto Attendance (#29955) * 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 * refactor: consider timeslots in `get_employee_shift` * fix: handle shift grace overlap while finding current shift * refactor: handle shifts spanning over 2 different days * fix: fetching shift on timing boundaries * refactor: rewrite docstrings and add type hints for functions * refactor: Allow multiple attendance records creation for different shifts * feat: auto attendance marking for multiple shifts on the same day * 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 * chore: sort imports, remove unused imports * refactor: Monthly Attendance Sheet - split into smaller functions - add type hints - get rid of unnecessary db calls and loops - add docstrings for functions * feat: add colors for attendance status to lessen the cognitive load - legend with colors and full form for status abbreviations * feat: show shift-wise attendance in monthly attendance sheet * test: monthly attendance sheet * style: format code with black * chore: ignore formatting changes in blame * test: fetching shifts in Employee Checkins * fix(test): make holiday list for shift and checkin tests * fix: tests * test: shift assignment creation * 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 * test: Shift Type with Auto Attendance setup and working fix test setups * refactor: Overlapping validation for Shift Request - commonify time overlap function between request and assignment - add tests for shift request overlap * chore: remove unused import * 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 * test: validations for duplicate and overlapping shift attendance records * test: skip auto attendance * fix: skip validation for overlapping shift attendance if no shift is linked * test: add holiday related shift and attendance tests * test: add attendance sheet tests for employee filter, half days * fix: sider --- .git-blame-ignore-revs | 3 + erpnext/hr/doctype/attendance/attendance.py | 169 +++- .../hr/doctype/attendance/test_attendance.py | 109 ++- .../employee_checkin/employee_checkin.py | 30 +- .../employee_checkin/test_employee_checkin.py | 185 +++- .../shift_assignment/shift_assignment.py | 508 ++++++---- .../shift_assignment/test_shift_assignment.py | 82 +- .../hr/doctype/shift_request/shift_request.py | 87 +- .../shift_request/test_shift_request.py | 153 +++- erpnext/hr/doctype/shift_type/shift_type.py | 141 ++- .../hr/doctype/shift_type/test_shift_type.py | 378 +++++++- .../monthly_attendance_sheet.js | 23 +- .../monthly_attendance_sheet.py | 865 +++++++++++------- .../test_monthly_attendance_sheet.py | 215 ++++- 14 files changed, 2287 insertions(+), 661 deletions(-) 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 diff --git a/erpnext/hr/doctype/attendance/attendance.py b/erpnext/hr/doctype/attendance/attendance.py index 7f4bd83685..e43d40ef56 100644 --- a/erpnext/hr/doctype/attendance/attendance.py +++ b/erpnext/hr/doctype/attendance/attendance.py @@ -5,11 +5,21 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.utils import cint, cstr, formatdate, get_datetime, 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.doctype.shift_assignment.shift_assignment import has_overlapping_timings from erpnext.hr.utils import get_holiday_dates_for_employee, validate_active_employee +class DuplicateAttendanceError(frappe.ValidationError): + pass + + +class OverlappingShiftAttendanceError(frappe.ValidationError): + pass + + class Attendance(Document): def validate(self): from erpnext.controllers.status_updater import validate_status @@ -18,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() @@ -35,21 +46,35 @@ 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), + duplicate = get_duplicate_attendance_record( + self.employee, self.attendance_date, self.shift, self.name ) - if res: + + if duplicate: frappe.throw( - _("Attendance for employee {0} is already marked for the date {1}").format( - frappe.bold(self.employee), frappe.bold(self.attendance_date) - ) + _("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_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): @@ -103,6 +128,69 @@ 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) + + +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) + .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 = [] @@ -141,28 +229,39 @@ def add_attendance(events, start, end, conditions=None): def mark_attendance( - employee, attendance_date, status, shift=None, leave_type=None, ignore_validate=False + employee, + attendance_date, + status, + shift=None, + leave_type=None, + ignore_validate=False, + late_entry=False, + early_exit=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, - } - ) - 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/attendance/test_attendance.py b/erpnext/hr/doctype/attendance/test_attendance.py index 058bc93d72..762d0f7567 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) + 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) diff --git a/erpnext/hr/doctype/employee_checkin/employee_checkin.py b/erpnext/hr/doctype/employee_checkin/employee_checkin.py index 87f48b7e25..64eb019b00 100644 --- a/erpnext/hr/doctype/employee_checkin/employee_checkin.py +++ b/erpnext/hr/doctype/employee_checkin/employee_checkin.py @@ -7,6 +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, + get_overlapping_shift_attendance, +) from erpnext.hr.doctype.shift_assignment.shift_assignment import ( get_actual_start_end_datetime_of_shift, ) @@ -33,24 +37,24 @@ class EmployeeCheckin(Document): 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: if ( - shift_actual_timings[2].shift_type.determine_check_in_and_check_out + 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[2].shift_type.name + 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 @@ -136,10 +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 frappe.db.exists( - "Attendance", - {"employee": employee, "attendance_date": attendance_date, "docstatus": ("!=", "2")}, - ): + 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, @@ -232,7 +236,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/employee_checkin/test_employee_checkin.py b/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py index 97f76b0350..81b44f8fea 100644 --- a/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py +++ b/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py @@ -2,10 +2,19 @@ # 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, + 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 ( @@ -13,9 +22,22 @@ 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 -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") + + 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) @@ -103,6 +125,163 @@ 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( + 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) + + # 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_holiday_as_per_shift_holiday_list(self): + date = getdate() + from_date = get_year_start(date) + to_date = get_year_ending(date) + 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) + + 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) + + 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@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))] diff --git a/erpnext/hr/doctype/shift_assignment/shift_assignment.py b/erpnext/hr/doctype/shift_assignment/shift_assignment.py index f6bd15951d..0b21c00eac 100644 --- a/erpnext/hr/doctype/shift_assignment/shift_assignment.py +++ b/erpnext/hr/doctype/shift_assignment/shift_assignment.py @@ -3,83 +3,120 @@ from datetime import datetime, timedelta +from typing import Dict, List 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_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 +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 = 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 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.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 throw_overlap_error(self, shift_details): shift_details = frappe._dict(shift_details) 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) + 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), ) - 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 msg: - frappe.throw(msg, title=title) + 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() @@ -155,102 +192,195 @@ def get_shift_type_timing(shift_types): return shift_timing_map +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) + + 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 + + return get_exact_shift(valid_shifts, for_timestamp) or {} + + return (valid_shifts and valid_shifts[0]) or {} + + +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 ( + 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: str, for_timestamp: datetime) -> Dict: + shifts = get_shifts_for_date(employee, for_timestamp) + if shifts: + return get_shift_for_time(shifts, for_timestamp) + return {} + + def get_employee_shift( - employee, for_date=None, consider_default_shift=False, next_shift_direction=None -): + 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. - :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() + if for_timestamp is None: + for_timestamp = now_datetime() + + 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") - 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 not shift_details and consider_default_shift: + shift_details = get_shift_details(default_shift, for_timestamp) + + # if its a holiday, reset + if shift_details and is_holiday_date(employee, shift_details): + shift_details = None + + # 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 or {} + + +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 + 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[0], for_timestamp.time()), consider_default_shift, None + ) + if shift_details: + break + + return shift_details or {} + + +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 shift_assignment_details: - shift_type_name = shift_assignment_details[0] + if not holiday_list_name: + holiday_list_name = get_holiday_list_for_employee(employee, False) - # 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 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 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 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 get_shift_details(shift_type_name, for_date) + 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() + # 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", + employee, curr_shift.start_datetime + timedelta(days=1), consider_default_shift, "forward" ) prev_shift = get_employee_shift( - employee, for_timestamp.date() + timedelta(days=-1), consider_default_shift, "reverse" + 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 @@ -273,31 +403,102 @@ def get_employee_shift_timings(employee, for_timestamp=None, consider_default_sh if curr_shift.actual_end > next_shift.actual_start else curr_shift.actual_end ) + return prev_shift, curr_shift, next_shift -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: - '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_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. - :param shift_type_name: shift type name for which shift_details is required. - :param for_date: Date on which shift_details are required + :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 + ) + return get_exact_shift(shift_timings_as_per_timestamp, for_timestamp) + + +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: + 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 not timestamp: + continue + + if for_timestamp < timestamp: + timestamp_index = index + elif for_timestamp == 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: + shift_details = shifts[int((timestamp_index - 1) / 2)] + + return shift_details + + +def get_shift_details(shift_type_name: str, for_timestamp: datetime = None) -> Dict: + """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) + + :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 None - if not for_date: - for_date = nowdate() + return {} + + if for_timestamp is None: + for_timestamp = now_datetime() + 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 + shift_actual_start = shift_type.start_time - timedelta( + minutes=shift_type.begin_check_in_before_shift_start_time ) - end_datetime = datetime.combine(for_date, datetime.min.time()) + shift_type.end_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, 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, 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 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 + actual_start = start_datetime - timedelta( minutes=shift_type.begin_check_in_before_shift_start_time ) @@ -312,34 +513,3 @@ def get_shift_details(shift_type_name, for_date=None): "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 diff --git a/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py b/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py index 4a1ec293bd..0fe9108168 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 make_shift_assignment, 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,65 @@ 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 11-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 11-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) diff --git a/erpnext/hr/doctype/shift_request/shift_request.py b/erpnext/hr/doctype/shift_request/shift_request.py index b5beef7a99..2bee2404aa 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 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) diff --git a/erpnext/hr/doctype/shift_type/shift_type.py b/erpnext/hr/doctype/shift_type/shift_type.py index 3f5cb222bf..5e214cf7b7 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, ) @@ -30,8 +32,9 @@ class ShiftType(Document): or not self.last_sync_of_checkin ): return + filters = { - "skip_auto_attendance": "0", + "skip_auto_attendance": 0, "attendance": ("is", "not set"), "time": (">=", self.process_attendance_after), "shift_actual_end": ("<", self.last_sync_of_checkin), @@ -40,6 +43,7 @@ class ShiftType(Document): 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"]) ): @@ -52,6 +56,7 @@ class ShiftType(Document): in_time, out_time, ) = self.get_attendance(single_shift_logs) + mark_attendance_and_link_log( single_shift_logs, attendance_status, @@ -63,15 +68,16 @@ class ShiftType(Document): 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 + 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( @@ -91,39 +97,68 @@ 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): """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. """ + start_date, end_date = self.get_start_and_end_dates(employee) + + # no shift assignment found, no need to process absent attendance records + if start_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) + + 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_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"] ) + 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 - ) + end_date = None + + shift_details = get_shift_details(self.name, get_datetime(self.last_sync_of_checkin)) 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" + 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 = ( min(prev_shift.start_datetime.date(), relieving_date) @@ -131,28 +166,21 @@ class ShiftType(Document): else prev_shift.start_datetime.date() ) else: - return - holiday_list_name = self.holiday_list - if not holiday_list_name: - holiday_list_name = get_holiday_list_for_employee(employee, False) - 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) - if shift_details and shift_details.shift_type.name == self.name: - mark_attendance(employee, date, "Absent", self.name) + # no shift found + return None, None + return start_date, end_date 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"] + filters = {"shift_type": self.name, "docstatus": "1"} + if from_date: + filters["start_date"] = (">", from_date) - assigned_employees = frappe.get_all("Shift Assignment", "employee", filters, as_list=True) - assigned_employees = [x[0] for x in assigned_employees] + 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] + default_shift_employees = frappe.get_all("Employee", filters=filters, pluck="name") + return list(set(assigned_employees + default_shift_employees)) return assigned_employees @@ -162,42 +190,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] diff --git a/erpnext/hr/doctype/shift_type/test_shift_type.py b/erpnext/hr/doctype/shift_type/test_shift_type.py index 7d2f29cd6c..0d75292a1e 100644 --- a/erpnext/hr/doctype/shift_type/test_shift_type.py +++ b/erpnext/hr/doctype/shift_type/test_shift_type.py @@ -2,7 +2,381 @@ # See license.txt import unittest +from datetime import datetime, timedelta + +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 +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(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") + + 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 + + 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") + + @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() + + 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 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) + date = getdate() + + 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() + timedelta(days=1), + } + ) + + 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 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..6f4bbd54fb 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) { @@ -78,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 8ea49899f2..efd2d382d5 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,618 @@ from calendar import monthrange +from itertools import groupby +from typing import Dict, List, Optional, Tuple import frappe -from frappe import _, msgprint +from frappe import _ +from frappe.query_builder.functions import Count, Extract, Sum from frappe.utils import cint, cstr, getdate +Filters = frappe._dict + status_map = { + "Present": "P", "Absent": "A", "Half Day": "HD", - "Holiday": "H", - "Weekly Off": "WO", - "On Leave": "L", - "Present": "P", "Work From Home": "WFH", + "On Leave": "L", + "Holiday": "H", + "Weekly Off": "WO", } day_abbr = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] -def execute(filters=None): - if not filters: - filters = {} +def execute(filters: Optional[Filters] = None) -> Tuple: + filters = frappe._dict(filters or {}) - if filters.hide_year_field == 1: - filters.year = 2020 + if not (filters.month and filters.year): + frappe.throw(_("Please select month and year.")) - conditions, filters = get_conditions(filters) - columns, days = get_columns(filters) - att_map = get_attendance_list(conditions, filters) - if not att_map: + 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 = 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 - 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"]] + message = get_message() if not filters.summarized_view else "" + 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, message, 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 +def get_message() -> str: + message = "" + colors = ["green", "red", "orange", "green", "#318AD8", "", ""] - datasets[0]["values"].append(total_absent_on_day) - datasets[1]["values"].append(total_present_on_day) - datasets[2]["values"].append(total_leave_on_day) + count = 0 + for status, abbr in status_map.items(): + message += f""" + + {status} - {abbr} + + """ + count += 1 - chart = {"data": {"labels": labels, "datasets": datasets}} - - chart["type"] = "line" - - return chart + return message -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 - - 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", + columns.extend( + [ + { + "label": _("Employee"), + "fieldname": "employee", + "fieldtype": "Link", + "options": "Employee", + "width": 135, + }, + {"label": _("Employee Name"), "fieldname": "employee_name", "fieldtype": "Data", "width": 120}, ] - return columns, days - - -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, ) - if not attendance_list: - msgprint(_("No attendance record found"), alert=True, indicator="orange") + 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(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, + }, + ] + ) + else: + 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") + 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, 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: + 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], filters, holiday_map, attendance_map) + + if records: + data.append({group_by_column: frappe.bold(value)}) + data.extend(records) + else: + data = get_rows(employee_details, filters, holiday_map, attendance_map) + + return data + + +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', ...} + } + } + """ + Attendance = frappe.qb.DocType("Attendance") + query = ( + frappe.qb.from_(Attendance) + .select( + Attendance.employee, + Extract("day", Attendance.attendance_date).as_("day_of_month"), + Attendance.status, + Attendance.shift, + ) + .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) + attendance_map = {} - att_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.shift, frappe._dict()) + attendance_map[d.employee][d.shift][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 + """ + 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 -@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""" +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") + + for employee, details in employee_details.items(): + emp_holiday_list = details.holiday_list or default_holiday_list + holidays = holiday_map.get(emp_holiday_list) + + 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) + 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 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} + """ + summary, attendance_days = get_attendance_summary_and_days(employee, filters) + if not any(summary.values()): + return {} + + total_days = get_total_days_in_month(filters) + total_holidays = total_unmarked_days = 0 + + for day in range(1, total_days + 1): + if day in attendance_days: + continue + + 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) ) - if not year_list: + 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) + + abbr = status_map.get(status, "") + row[day] = abbr + + attendance_values.append(row) + + return attendance_values + + +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.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) + ).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.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) + + return entry_exits[0] + + +@frappe.whitelist() +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_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 + + 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", + "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 91da08eee5..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 @@ -1,18 +1,32 @@ 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.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_holiday_list, + 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") + date = getdate() + from_date = get_year_start(date) + to_date = get_year_ending(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): now = now_datetime() previous_month = now.month - 1 @@ -33,14 +47,203 @@ class TestMonthlyAttendanceSheet(FrappeTestCase): } ) report = execute(filters=filters) - employees = report[1][0] + + 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) + # 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_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=2), "Half Day" + ) # half day + + 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) + + # 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) + 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 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 + + 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() + 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") + + +def execute_report_with_invalid_filters(): + filters = frappe._dict({"company": "_Test Company", "group_by": "Department"}) + execute(filters=filters) From c5850e3923e3c5101413750e8e01a800c6945c1f Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Wed, 6 Apr 2022 11:18:47 +0530 Subject: [PATCH 46/76] Revert "feat: Scheduling Multiple shifts and Auto Attendance" (#30587) --- .git-blame-ignore-revs | 3 - erpnext/hr/doctype/attendance/attendance.py | 169 +--- .../hr/doctype/attendance/test_attendance.py | 109 +-- .../employee_checkin/employee_checkin.py | 30 +- .../employee_checkin/test_employee_checkin.py | 185 +--- .../shift_assignment/shift_assignment.py | 504 ++++------ .../shift_assignment/test_shift_assignment.py | 82 +- .../hr/doctype/shift_request/shift_request.py | 87 +- .../shift_request/test_shift_request.py | 153 +--- erpnext/hr/doctype/shift_type/shift_type.py | 143 +-- .../hr/doctype/shift_type/test_shift_type.py | 378 +------- .../monthly_attendance_sheet.js | 23 +- .../monthly_attendance_sheet.py | 859 ++++++------------ .../test_monthly_attendance_sheet.py | 215 +---- 14 files changed, 657 insertions(+), 2283 deletions(-) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 3bc22af96a..e9cb6cf903 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -26,6 +26,3 @@ b147b85e6ac19a9220cd1e2958a6ebd99373283a # bulk format python code with black 494bd9ef78313436f0424b918f200dab8fc7c20b - -# bulk format python code with black -baec607ff5905b1c67531096a9cf50ec7ff00a5d \ No newline at end of file diff --git a/erpnext/hr/doctype/attendance/attendance.py b/erpnext/hr/doctype/attendance/attendance.py index e43d40ef56..7f4bd83685 100644 --- a/erpnext/hr/doctype/attendance/attendance.py +++ b/erpnext/hr/doctype/attendance/attendance.py @@ -5,21 +5,11 @@ import frappe from frappe import _ 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 frappe.utils import cint, cstr, formatdate, get_datetime, 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 -class DuplicateAttendanceError(frappe.ValidationError): - pass - - -class OverlappingShiftAttendanceError(frappe.ValidationError): - pass - - class Attendance(Document): def validate(self): from erpnext.controllers.status_updater import validate_status @@ -28,7 +18,6 @@ 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() @@ -46,35 +35,21 @@ 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 + 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 duplicate: + if res: 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_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, + _("Attendance for employee {0} is already marked for the date {1}").format( + frappe.bold(self.employee), frappe.bold(self.attendance_date) + ) ) def validate_employee_status(self): @@ -128,69 +103,6 @@ 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) - - -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) - .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 = [] @@ -229,39 +141,28 @@ def add_attendance(events, start, end, conditions=None): def mark_attendance( - employee, - attendance_date, - status, - shift=None, - leave_type=None, - ignore_validate=False, - late_entry=False, - early_exit=False, + employee, attendance_date, status, shift=None, leave_type=None, ignore_validate=False ): - 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 + 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, + } + ) + attendance.flags.ignore_validate = ignore_validate + attendance.insert() + attendance.submit() + return attendance.name @frappe.whitelist() diff --git a/erpnext/hr/doctype/attendance/test_attendance.py b/erpnext/hr/doctype/attendance/test_attendance.py index 762d0f7567..058bc93d72 100644 --- a/erpnext/hr/doctype/attendance/test_attendance.py +++ b/erpnext/hr/doctype/attendance/test_attendance.py @@ -6,8 +6,6 @@ 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, @@ -25,112 +23,11 @@ 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) - 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"} @@ -145,6 +42,7 @@ 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) @@ -169,6 +67,8 @@ 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) @@ -195,6 +95,7 @@ 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) diff --git a/erpnext/hr/doctype/employee_checkin/employee_checkin.py b/erpnext/hr/doctype/employee_checkin/employee_checkin.py index 64eb019b00..87f48b7e25 100644 --- a/erpnext/hr/doctype/employee_checkin/employee_checkin.py +++ b/erpnext/hr/doctype/employee_checkin/employee_checkin.py @@ -7,10 +7,6 @@ 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, - get_overlapping_shift_attendance, -) from erpnext.hr.doctype.shift_assignment.shift_assignment import ( get_actual_start_end_datetime_of_shift, ) @@ -37,24 +33,24 @@ class EmployeeCheckin(Document): 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[0] and shift_actual_timings[1]: if ( - shift_actual_timings.shift_type.determine_check_in_and_check_out + 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.shift_type.name + shift_actual_timings[2].shift_type.name ) ) if not self.attendance: - 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 + self.shift = shift_actual_timings[2].shift_type.name + self.shift_actual_start = shift_actual_timings[0] + self.shift_actual_end = shift_actual_timings[1] + self.shift_start = shift_actual_timings[2].start_datetime + self.shift_end = shift_actual_timings[2].end_datetime else: self.shift = None @@ -140,10 +136,10 @@ def mark_attendance_and_link_log( return None elif attendance_status in ("Present", "Absent", "Half Day"): employee_doc = frappe.get_doc("Employee", employee) - duplicate = get_duplicate_attendance_record(employee, attendance_date, shift) - overlapping = get_overlapping_shift_attendance(employee, attendance_date, shift) - - if not duplicate and not overlapping: + if not frappe.db.exists( + "Attendance", + {"employee": employee, "attendance_date": attendance_date, "docstatus": ("!=", "2")}, + ): doc_dict = { "doctype": "Attendance", "employee": employee, @@ -236,7 +232,7 @@ def calculate_working_hours(logs, check_in_out_type, working_hours_calc_type): def time_diff_in_hours(start, end): - return round(float((end - start).total_seconds()) / 3600, 2) + return round((end - start).total_seconds() / 3600, 1) def find_index_in_dict(dict_list, key, value): diff --git a/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py b/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py index 81b44f8fea..97f76b0350 100644 --- a/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py +++ b/erpnext/hr/doctype/employee_checkin/test_employee_checkin.py @@ -2,19 +2,10 @@ # See license.txt import unittest -from datetime import datetime, timedelta +from datetime import timedelta 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, - nowdate, -) +from frappe.utils import now_datetime, nowdate from erpnext.hr.doctype.employee.test_employee import make_employee from erpnext.hr.doctype.employee_checkin.employee_checkin import ( @@ -22,22 +13,9 @@ 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 -class TestEmployeeCheckin(FrappeTestCase): - def setUp(self): - frappe.db.delete("Shift Type") - 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) - +class TestEmployeeCheckin(unittest.TestCase): 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) @@ -125,163 +103,6 @@ class TestEmployeeCheckin(FrappeTestCase): ) 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( - 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) - - # 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_holiday_as_per_shift_holiday_list(self): - date = getdate() - from_date = get_year_start(date) - to_date = get_year_ending(date) - 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) - - 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) - - 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@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))] diff --git a/erpnext/hr/doctype/shift_assignment/shift_assignment.py b/erpnext/hr/doctype/shift_assignment/shift_assignment.py index 0b21c00eac..f6bd15951d 100644 --- a/erpnext/hr/doctype/shift_assignment/shift_assignment.py +++ b/erpnext/hr/doctype/shift_assignment/shift_assignment.py @@ -3,120 +3,83 @@ 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 -from frappe.utils import cstr, get_datetime, get_link_to_form, get_time, getdate, now_datetime +from frappe.utils import cstr, 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_shifts() + self.validate_overlapping_dates() if self.end_date: self.validate_from_to_dates("start_date", "end_date") - 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 = 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): + def validate_overlapping_dates(self): if not self.name: self.name = "New Shift Assignment" - shift = frappe.qb.DocType("Shift Assignment") - query = ( - frappe.qb.from_(shift) - .select(shift.name, shift.shift_type, shift.docstatus, shift.status) - .where( - (shift.employee == self.employee) - & (shift.docstatus == 1) - & (shift.name != self.name) - & (shift.status == "Active") - ) - ) + condition = """and ( + end_date is null + or + %(start_date)s between start_date and end_date + """ 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), - ] - ), - ] - ) - ) + condition += """ or + %(end_date)s between start_date and end_date + or + start_date between %(start_date)s and %(end_date)s + ) """ else: - query = query.where( - shift.end_date.isnull() - | ((self.start_date >= shift.start_date) & (self.start_date <= shift.end_date)) - ) + condition += """ ) """ - return query.run(as_dict=True) + 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, + ) + + if len(assigned_shifts): + self.throw_overlap_error(assigned_shifts[0]) def throw_overlap_error(self, shift_details): shift_details = frappe._dict(shift_details) if shift_details.docstatus == 1 and shift_details.status == "Active": - 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), + msg = _("Employee {0} already has Active Shift {1}: {2}").format( + frappe.bold(self.employee), frappe.bold(self.shift_type), frappe.bold(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 + 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 msg: + frappe.throw(msg, title=title) @frappe.whitelist() @@ -192,195 +155,102 @@ def get_shift_type_timing(shift_types): return shift_timing_map -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) - - 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 - - return get_exact_shift(valid_shifts, for_timestamp) or {} - - return (valid_shifts and valid_shifts[0]) or {} - - -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 ( - 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: str, for_timestamp: datetime) -> Dict: - shifts = get_shifts_for_date(employee, for_timestamp) - if shifts: - return get_shift_for_time(shifts, for_timestamp) - return {} - - def get_employee_shift( - employee: str, - for_timestamp: datetime = None, - consider_default_shift: bool = False, - next_shift_direction: str = None, -) -> Dict: + employee, for_date=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_timestamp: DateTime on which shift is required + :param for_date: Date on which shift are 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_timestamp is None: - for_timestamp = now_datetime() - - shift_details = get_shift_for_timestamp(employee, for_timestamp) - - # if shift assignment is not found, consider default shift + if for_date is None: + for_date = nowdate() 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) - - # if its a holiday, reset - if shift_details and is_holiday_date(employee, shift_details): - shift_details = None - - # 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 or {} - - -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 - 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[0], for_timestamp.time()), consider_default_shift, None - ) - if shift_details: - break - - return shift_details or {} - - -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" + 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 not holiday_list_name: - holiday_list_name = get_holiday_list_for_employee(employee, False) + if shift_assignment_details: + shift_type_name = shift_assignment_details[0] - return holiday_list_name and is_holiday(holiday_list_name, shift_details.start_datetime.date()) + # 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 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 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 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 get_shift_details(shift_type_name, for_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, 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, consider_default_shift, "forward") + curr_shift = get_employee_shift(employee, for_timestamp.date(), consider_default_shift, "forward") if curr_shift: next_shift = get_employee_shift( - employee, curr_shift.start_datetime + timedelta(days=1), consider_default_shift, "forward" + employee, + curr_shift.start_datetime.date() + timedelta(days=1), + consider_default_shift, + "forward", ) prev_shift = get_employee_shift( - employee, for_timestamp + timedelta(days=-1), consider_default_shift, "reverse" + employee, for_timestamp.date() + 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 @@ -403,102 +273,31 @@ def get_employee_shift_timings( 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: - """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. +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: + '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) - :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 - ) - return get_exact_shift(shift_timings_as_per_timestamp, for_timestamp) - - -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: - 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 not timestamp: - continue - - if for_timestamp < timestamp: - timestamp_index = index - elif for_timestamp == 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: - shift_details = shifts[int((timestamp_index - 1) / 2)] - - return shift_details - - -def get_shift_details(shift_type_name: str, for_timestamp: datetime = None) -> Dict: - """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) - - :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 + :param shift_type_name: shift type name for which shift_details is required. + :param for_date: Date on which shift_details are required """ if not shift_type_name: - return {} - - if for_timestamp is None: - for_timestamp = now_datetime() - + return None + if not for_date: + for_date = nowdate() 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 + 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 ) - - 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, 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, 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 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 - + 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 ) @@ -513,3 +312,34 @@ def get_shift_details(shift_type_name: str, for_timestamp: datetime = None) -> D "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 diff --git a/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py b/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py index 0fe9108168..4a1ec293bd 100644 --- a/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py +++ b/erpnext/hr/doctype/shift_assignment/test_shift_assignment.py @@ -4,23 +4,16 @@ import unittest import frappe -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 make_shift_assignment, setup_shift_type +from frappe.utils import add_days, nowdate test_dependencies = ["Shift Type"] -class TestShiftAssignment(FrappeTestCase): +class TestShiftAssignment(unittest.TestCase): def setUp(self): - frappe.db.delete("Shift Assignment") - frappe.db.delete("Shift Type") + frappe.db.sql("delete from `tabShift Assignment`") def test_make_shift_assignment(self): - setup_shift_type(shift_type="Day Shift") shift_assignment = frappe.get_doc( { "doctype": "Shift Assignment", @@ -36,7 +29,7 @@ class TestShiftAssignment(FrappeTestCase): 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", @@ -61,11 +54,11 @@ class TestShiftAssignment(FrappeTestCase): } ) - self.assertRaises(OverlappingShiftError, shift_assignment.save) + self.assertRaises(frappe.ValidationError, 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", @@ -92,65 +85,4 @@ class TestShiftAssignment(FrappeTestCase): } ) - 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 11-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 11-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) + self.assertRaises(frappe.ValidationError, shift_assignment_3.save) diff --git a/erpnext/hr/doctype/shift_request/shift_request.py b/erpnext/hr/doctype/shift_request/shift_request.py index 2bee2404aa..b5beef7a99 100644 --- a/erpnext/hr/doctype/shift_request/shift_request.py +++ b/erpnext/hr/doctype/shift_request/shift_request.py @@ -5,14 +5,12 @@ import frappe from frappe import _ from frappe.model.document import Document -from frappe.query_builder import Criterion -from frappe.utils import get_link_to_form, getdate +from frappe.utils import formatdate, 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 OverlappingShiftRequestError(frappe.ValidationError): +class OverlapError(frappe.ValidationError): pass @@ -20,7 +18,7 @@ class ShiftRequest(Document): def validate(self): validate_active_employee(self.employee) self.validate_dates() - self.validate_overlapping_shift_requests() + self.validate_shift_request_overlap_dates() self.validate_approver() self.validate_default_shift() @@ -81,60 +79,37 @@ 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_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): + def validate_shift_request_overlap_dates(self): if not self.name: self.name = "New Shift Request" - 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)) + 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, ) - 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)) - ) + for date_overlap in d: + if date_overlap["name"]: + self.throw_overlap_error(date_overlap) - 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) + 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) diff --git a/erpnext/hr/doctype/shift_request/test_shift_request.py b/erpnext/hr/doctype/shift_request/test_shift_request.py index c47418cfa8..b4f5177215 100644 --- a/erpnext/hr/doctype/shift_request/test_shift_request.py +++ b/erpnext/hr/doctype/shift_request/test_shift_request.py @@ -4,24 +4,23 @@ 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(FrappeTestCase): +class TestShiftRequest(unittest.TestCase): def setUp(self): - for doctype in ["Shift Request", "Shift Assignment", "Shift Type"]: - frappe.db.delete(doctype) + for doctype in ["Shift Request", "Shift Assignment"]: + frappe.db.sql("delete from `tab{doctype}`".format(doctype=doctype)) + + def tearDown(self): + frappe.db.rollback() 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( @@ -49,7 +48,6 @@ class TestShiftRequest(FrappeTestCase): 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") @@ -89,145 +87,6 @@ class TestShiftRequest(FrappeTestCase): 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) diff --git a/erpnext/hr/doctype/shift_type/shift_type.py b/erpnext/hr/doctype/shift_type/shift_type.py index 5e214cf7b7..3f5cb222bf 100644 --- a/erpnext/hr/doctype/shift_type/shift_type.py +++ b/erpnext/hr/doctype/shift_type/shift_type.py @@ -3,23 +3,21 @@ import itertools -from datetime import datetime, timedelta +from datetime import timedelta import frappe from frappe.model.document import Document -from frappe.utils import cint, get_datetime, get_time, getdate +from frappe.utils import cint, get_datetime, 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, ) @@ -32,9 +30,8 @@ class ShiftType(Document): or not self.last_sync_of_checkin ): return - filters = { - "skip_auto_attendance": 0, + "skip_auto_attendance": "0", "attendance": ("is", "not set"), "time": (">=", self.process_attendance_after), "shift_actual_end": ("<", self.last_sync_of_checkin), @@ -43,7 +40,6 @@ class ShiftType(Document): 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"]) ): @@ -56,7 +52,6 @@ class ShiftType(Document): in_time, out_time, ) = self.get_attendance(single_shift_logs) - mark_attendance_and_link_log( single_shift_logs, attendance_status, @@ -68,16 +63,15 @@ class ShiftType(Document): 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. - 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 + Assumtion: + 1. These logs belongs to an single shift, single employee and is 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( @@ -97,68 +91,39 @@ class ShiftType(Document): ): early_exit = True - 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 + 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 return "Present", total_working_hours, late_entry, early_exit, in_time, out_time def mark_absent_for_dates_with_no_attendance(self, employee): """Marks Absents for the given employee on working days in this shift which have no attendance marked. The Absent is marked starting from 'process_attendance_after' or employee creation date. """ - start_date, end_date = self.get_start_and_end_dates(employee) - - # no shift assignment found, no need to process absent attendance records - if start_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) - - 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_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"] ) - 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) + 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" ) - - # 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 = ( min(prev_shift.start_datetime.date(), relieving_date) @@ -166,21 +131,28 @@ class ShiftType(Document): else prev_shift.start_datetime.date() ) else: - # no shift found - return None, None - return start_date, end_date + 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, date, 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 = {"shift_type": self.name, "docstatus": "1"} - if from_date: - filters["start_date"] = (">", from_date) + filters = {"start_date": (">", from_date), "shift_type": self.name, "docstatus": "1"} + if not from_date: + del filters["start_date"] - assigned_employees = frappe.get_all("Shift Assignment", filters=filters, pluck="employee") + assigned_employees = frappe.get_all("Shift Assignment", "employee", filters, as_list=True) + assigned_employees = [x[0] for x in assigned_employees] if consider_default_shift: filters = {"default_shift": self.name, "status": ["!=", "Inactive"]} - default_shift_employees = frappe.get_all("Employee", filters=filters, pluck="name") - + default_shift_employees = frappe.get_all("Employee", "name", filters, as_list=True) + default_shift_employees = [x[0] for x in default_shift_employees] return list(set(assigned_employees + default_shift_employees)) return assigned_employees @@ -190,3 +162,42 @@ 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] diff --git a/erpnext/hr/doctype/shift_type/test_shift_type.py b/erpnext/hr/doctype/shift_type/test_shift_type.py index 0d75292a1e..7d2f29cd6c 100644 --- a/erpnext/hr/doctype/shift_type/test_shift_type.py +++ b/erpnext/hr/doctype/shift_type/test_shift_type.py @@ -2,381 +2,7 @@ # See license.txt import unittest -from datetime import datetime, timedelta -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 -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): - def setUp(self): - frappe.db.delete("Shift Type") - frappe.db.delete("Shift Assignment") - 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 - - 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") - - @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() - - 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 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) - date = getdate() - - 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() + timedelta(days=1), - } - ) - - 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 +class TestShiftType(unittest.TestCase): + pass 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 6f4bbd54fb..42f7cdb50f 100644 --- a/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.js +++ b/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.js @@ -66,7 +66,8 @@ 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) { @@ -77,25 +78,5 @@ 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 efd2d382d5..8ea49899f2 100644 --- a/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py +++ b/erpnext/hr/report/monthly_attendance_sheet/monthly_attendance_sheet.py @@ -3,618 +3,365 @@ from calendar import monthrange -from itertools import groupby -from typing import Dict, List, Optional, Tuple import frappe -from frappe import _ -from frappe.query_builder.functions import Count, Extract, Sum +from frappe import _, msgprint from frappe.utils import cint, cstr, getdate -Filters = frappe._dict - status_map = { - "Present": "P", "Absent": "A", "Half Day": "HD", - "Work From Home": "WFH", + "Holiday": "H", + "Weekly Off": "WO", "On Leave": "L", - "Holiday": "H", - "Weekly Off": "WO", + "Present": "P", + "Work From Home": "WFH", } 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 - 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 = get_data(filters, attendance_map) - - if not data: - frappe.msgprint( - _("No attendance records found for this criteria."), alert=True, indicator="orange" - ) + conditions, filters = get_conditions(filters) + columns, days = get_columns(filters) + att_map = get_attendance_list(conditions, filters) + if not att_map: return columns, [], None, None - message = get_message() if not filters.summarized_view else "" - chart = get_chart_data(attendance_map, filters) + 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"]] - return columns, data, message, chart + 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 -def get_message() -> str: - message = "" - colors = ["green", "red", "orange", "green", "#318AD8", "", ""] +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 - count = 0 - for status, abbr in status_map.items(): - message += f""" - - {status} - {abbr} - - """ - count += 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) - return message + chart = {"data": {"labels": labels, "datasets": datasets}} + + chart["type"] = "line" + + return chart -def get_columns(filters: Filters) -> List[Dict]: +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): + columns = [] if filters.group_by: - columns.append( - { - "label": _(filters.group_by), - "fieldname": frappe.scrub(filters.group_by), - "fieldtype": "Link", - "options": "Branch", - "width": 120, - } - ) + columns = [_(filters.group_by) + ":Link/Branch:120"] - columns.extend( - [ - { - "label": _("Employee"), - "fieldname": "employee", - "fieldtype": "Link", - "options": "Employee", - "width": 135, - }, - {"label": _("Employee Name"), "fieldname": "employee_name", "fieldtype": "Data", "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 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(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, - }, - ] - ) - else: - columns.append({"label": _("Shift"), "fieldname": "shift", "fieldtype": "Data", "width": 120}) - columns.extend(get_columns_for_days(filters)) - - return columns + columns += [ + _("Total Present") + ":Float:120", + _("Total Leaves") + ":Float:120", + _("Total Absent") + ":Float:120", + _("Total Holidays") + ":Float:120", + _("Unmarked Days") + ":Float:120", + ] + return columns, days -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, attendance_map: Dict) -> List[Dict]: - employee_details, group_by_param_values = get_employee_related_details( - filters.group_by, filters.company +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, ) - 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], filters, holiday_map, attendance_map) - - if records: - data.append({group_by_column: frappe.bold(value)}) - data.extend(records) - else: - data = get_rows(employee_details, filters, holiday_map, attendance_map) - - return data - - -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', ...} - } - } - """ - Attendance = frappe.qb.DocType("Attendance") - query = ( - frappe.qb.from_(Attendance) - .select( - Attendance.employee, - Extract("day", Attendance.attendance_date).as_("day_of_month"), - Attendance.status, - Attendance.shift, - ) - .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) - attendance_map = {} + if not attendance_list: + msgprint(_("No attendance record found"), alert=True, indicator="orange") + att_map = {} for d in attendance_list: - attendance_map.setdefault(d.employee, frappe._dict()).setdefault(d.shift, frappe._dict()) - attendance_map[d.employee][d.shift][d.day_of_month] = d.status + att_map.setdefault(d.employee, frappe._dict()).setdefault(d.day_of_month, "") + att_map[d.employee][d.day_of_month] = d.status - return attendance_map + return att_map -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 - """ - 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) +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 ) if group_by: group_by = group_by.lower() - query = query.orderby(group_by) + query += " order by " + group_by + " ASC" - employee_details = query.run(as_dict=True) - - group_by_param_values = [] - emp_map = {} + employee_details = frappe.db.sql(query, as_dict=1) + 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()) - for emp in employees: - emp_map[parameter][emp.name] = emp + 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 else: - for emp in employee_details: - emp_map[emp.name] = emp - - return emp_map, group_by_param_values + return emp_map, group_by_parameters -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) - +def get_holiday(holiday_list, month): holiday_map = frappe._dict() - 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) + 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), + ), ) - ).run(as_dict=True) - - holiday_map.setdefault(d, holidays) return holiday_map -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") - - for employee, details in employee_details.items(): - emp_holiday_list = details.holiday_list or default_holiday_list - holidays = holiday_map.get(emp_holiday_list) - - 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) - 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 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} - """ - summary, attendance_days = get_attendance_summary_and_days(employee, filters) - if not any(summary.values()): - return {} - - total_days = get_total_days_in_month(filters) - total_holidays = total_unmarked_days = 0 - - for day in range(1, total_days + 1): - if day in attendance_days: - continue - - 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) - - abbr = status_map.get(status, "") - row[day] = abbr - - attendance_values.append(row) - - return attendance_values - - -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.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) - ).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.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) - - return entry_exits[0] - - @frappe.whitelist() -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: +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: year_list = [getdate().year] - 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_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 - - 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", - "colors": ["red", "green", "blue"], - } + return "\n".join(str(year) for year in year_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 cde7dd3fff..91da08eee5 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,32 +1,18 @@ import frappe from dateutil.relativedelta import relativedelta from frappe.tests.utils import FrappeTestCase -from frappe.utils import get_year_ending, get_year_start, getdate, now_datetime +from frappe.utils import now_datetime 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 -from erpnext.payroll.doctype.salary_slip.test_salary_slip import ( - make_holiday_list, - make_leave_application, -) - -test_dependencies = ["Shift Type"] class TestMonthlyAttendanceSheet(FrappeTestCase): def setUp(self): - self.employee = make_employee("test_employee@example.com", company="_Test Company") - frappe.db.delete("Attendance") + self.employee = make_employee("test_employee@example.com") + frappe.db.delete("Attendance", {"employee": self.employee}) - date = getdate() - from_date = get_year_start(date) - to_date = get_year_ending(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): now = now_datetime() previous_month = now.month - 1 @@ -47,203 +33,14 @@ class TestMonthlyAttendanceSheet(FrappeTestCase): } ) report = execute(filters=filters) - - record = report[1][0] + employees = 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")) + # ensure correct attendance is reflect on the report + self.assertIn(self.employee, employees) 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=2), "Half Day" - ) # half day - - 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) - - # 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) - 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 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 - - 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() - 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") - - -def execute_report_with_invalid_filters(): - filters = frappe._dict({"company": "_Test Company", "group_by": "Department"}) - execute(filters=filters) From 4895761d89df932e725f0923f0af900ada1486a8 Mon Sep 17 00:00:00 2001 From: HENRY Florian Date: Wed, 6 Apr 2022 10:30:05 +0200 Subject: [PATCH 47/76] fix: update translation (#30474) * fix: update translation * fix: update translation * fix: update translation * fix: update translation * fix: update translation * fix: update translation * fix: update translation * fix: update translation * fix: update translation * fix: update translation --- erpnext/translations/fr.csv | 49 +++++++++++++++++++++++++++++++------ 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/erpnext/translations/fr.csv b/erpnext/translations/fr.csv index 3cdae454ab..ea01ec63e4 100644 --- a/erpnext/translations/fr.csv +++ b/erpnext/translations/fr.csv @@ -285,7 +285,7 @@ Asset scrapped via Journal Entry {0},Actif mis au rebut via Écriture de Journal "Asset {0} cannot be scrapped, as it is already {1}","L'actif {0} ne peut pas être mis au rebut, car il est déjà {1}", Asset {0} does not belong to company {1},L'actif {0} ne fait pas partie à la société {1}, Asset {0} must be submitted,L'actif {0} doit être soumis, -Assets,Les atouts, +Assets,Actifs - Immo., Assign,Assigner, Assign Salary Structure,Affecter la structure salariale, Assign To,Attribuer À, @@ -1211,7 +1211,7 @@ Hello,Bonjour, Help Results for,Aide Résultats pour, High,Haut, High Sensitivity,Haute sensibilité, -Hold,Tenir, +Hold,Mettre en attente, Hold Invoice,Facture en attente, Holiday,Vacances, Holiday List,Liste de vacances, @@ -4240,7 +4240,7 @@ For Default Supplier (Optional),Pour le fournisseur par défaut (facultatif), From date cannot be greater than To date,La Date Initiale ne peut pas être postérieure à la Date Finale, Group by,Grouper Par, In stock,En stock, -Item name,Nom de l'article, +Item name,Libellé de l'article, Loan amount is mandatory,Le montant du prêt est obligatoire, Minimum Qty,Quantité minimum, More details,Plus de détails, @@ -5473,7 +5473,7 @@ Percentage you are allowed to transfer more against the quantity ordered. For ex PUR-ORD-.YYYY.-,PUR-ORD-.YYYY.-, Get Items from Open Material Requests,Obtenir des Articles de Demandes Matérielles Ouvertes, Fetch items based on Default Supplier.,Récupérez les articles en fonction du fournisseur par défaut., -Required By,Requis Par, +Required By,Requis pour le, Order Confirmation No,No de confirmation de commande, Order Confirmation Date,Date de confirmation de la commande, Customer Mobile No,N° de Portable du Client, @@ -7223,8 +7223,8 @@ Basic Rate (Company Currency),Taux de Base (Devise de la Société ), Scrap %,% de Rebut, Original Item,Article original, BOM Operation,Opération LDM, -Operation Time ,Moment de l'opération, -In minutes,En quelques minutes, +Operation Time ,Durée de l'opération, +In minutes,En minutes, Batch Size,Taille du lot, Base Hour Rate(Company Currency),Taux Horaire de Base (Devise de la Société), Operating Cost(Company Currency),Coût d'Exploitation (Devise Société), @@ -9267,7 +9267,7 @@ Sales Order Analysis,Analyse des commandes clients, Amount Delivered,Montant livré, Delay (in Days),Retard (en jours), Group by Sales Order,Regrouper par commande client, - Sales Value,La valeur des ventes, +Sales Value,La valeur des ventes, Stock Qty vs Serial No Count,Quantité de stock vs numéro de série, Serial No Count,Numéro de série, Work Order Summary,Résumé de l'ordre de travail, @@ -9647,7 +9647,7 @@ Allow Multiple Sales Orders Against a Customer's Purchase Order,Autoriser plusie Validate Selling Price for Item Against Purchase Rate or Valuation Rate,Valider le prix de vente de l'article par rapport au taux d'achat ou au taux de valorisation, Hide Customer's Tax ID from Sales Transactions,Masquer le numéro d'identification fiscale du client dans les transactions de vente, "The percentage you are allowed to receive or deliver more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed to receive 110 units.","Le pourcentage que vous êtes autorisé à recevoir ou à livrer plus par rapport à la quantité commandée. Par exemple, si vous avez commandé 100 unités et que votre allocation est de 10%, vous êtes autorisé à recevoir 110 unités.", -Action If Quality Inspection Is Not Submitted,Action si l'inspection de la qualité n'est pas soumise, +Action If Quality Inspection Is Not Submitted,Action si l'inspection qualité n'est pas soumise, Auto Insert Price List Rate If Missing,Taux de liste de prix d'insertion automatique s'il est manquant, Automatically Set Serial Nos Based on FIFO,Définir automatiquement les numéros de série en fonction de FIFO, Set Qty in Transactions Based on Serial No Input,Définir la quantité dans les transactions en fonction du numéro de série, @@ -9838,3 +9838,36 @@ Enable European Access,Activer l'accès européen, Creating Purchase Order ...,Création d'une commande d'achat ..., "Select a Supplier from the Default Suppliers of the items below. On selection, a Purchase Order will be made against items belonging to the selected Supplier only.","Sélectionnez un fournisseur parmi les fournisseurs par défaut des articles ci-dessous. Lors de la sélection, un bon de commande sera effectué contre des articles appartenant uniquement au fournisseur sélectionné.", Row #{}: You must select {} serial numbers for item {}.,Ligne n ° {}: vous devez sélectionner {} numéros de série pour l'article {}., +Update Rate as per Last Purchase,Mettre à jour avec les derniers prix d'achats +Company Shipping Address,Adresse d'expédition +Shipping Address Details,Détail d'adresse d'expédition +Company Billing Address,Adresse de la société de facturation +Supplier Address Details, +Bank Reconciliation Tool,Outil de réconcialiation d'écritures bancaires +Supplier Contact,Contact fournisseur +Subcontracting,Sous traitance +Order Status,Statut de la commande +Build,Personnalisations avancées +Dispatch Address Name,Adresse de livraison intermédiaire +Amount Eligible for Commission,Montant éligible à comission +Grant Commission,Eligible aux commissions +Stock Transactions Settings, Paramétre des transactions +Role Allowed to Over Deliver/Receive, Rôle autorisé à dépasser cette limite +Users with this role are allowed to over deliver/receive against orders above the allowance percentage,Rôle Utilisateur qui sont autorisé à livrée/commandé au-delà de la limite +Over Transfer Allowance,Autorisation de limite de transfert +The percentage you are allowed to transfer more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed transfer 110 units, Le pourcentage de quantité que vous pourrez receptionné en plus de la quantité commandée +Quality Inspection Settings,Paramétre de l'inspection qualité +Action If Quality Inspection Is Rejected,Action si l'inspection qualité est rejetée +Disable Serial No And Batch Selector,Désactiver le sélecteur de numéro de lot/série +Is Rate Adjustment Entry (Debit Note),Est un justement du prix de la note de débit +Issue a debit note with 0 qty against an existing Sales Invoice,Creer une note de débit avec une quatité à O pour la facture +Control Historical Stock Transactions,Controle de l'historique des stransaction de stock +No stock transactions can be created or modified before this date.,Aucune transaction ne peux être créée ou modifié avant cette date. +Stock transactions that are older than the mentioned days cannot be modified.,Les transactions de stock plus ancienne que le nombre de jours ci-dessus ne peuvent être modifiées +Role Allowed to Create/Edit Back-dated Transactions,Rôle autorisé à créer et modifier des transactions anti-datée +If mentioned, the system will allow only the users with this Role to create or modify any stock transaction earlier than the latest stock transaction for a specific item and warehouse. If set as blank, it allows all users to create/edit back-dated transactions.,LEs utilisateur de ce role pourront creer et modifier des transactions dans le passé. Si vide tout les utilisateurs pourrons le faire +Auto Insert Item Price If Missing,Création du prix de l'article dans les listes de prix si abscent +Update Existing Price List Rate,Mise a jour automatique du prix dans les listes de prix +Show Barcode Field in Stock Transactions,Afficher le champ Code Barre dans les transactions de stock +Convert Item Description to Clean HTML in Transactions,Convertir les descriptions d'articles en HTML valide lors des transactions +Have Default Naming Series for Batch ID?,Nom de série par défaut pour les Lots ou Séries From bb875fe217d5d5ac61a01e564fb115c2f1989788 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 6 Apr 2022 14:20:39 +0530 Subject: [PATCH 48/76] fix: check null values in is_cancelled patch (#30594) --- erpnext/patches/v12_0/update_is_cancelled_field.py | 2 +- erpnext/patches/v14_0/change_is_subcontracted_fieldtype.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/patches/v12_0/update_is_cancelled_field.py b/erpnext/patches/v12_0/update_is_cancelled_field.py index b567823b06..398dd700ed 100644 --- a/erpnext/patches/v12_0/update_is_cancelled_field.py +++ b/erpnext/patches/v12_0/update_is_cancelled_field.py @@ -20,7 +20,7 @@ def execute(): """ UPDATE `tab{doctype}` SET is_cancelled = 0 - where is_cancelled in ('', NULL, 'No')""".format( + where is_cancelled in ('', 'No') or is_cancelled is NULL""".format( doctype=doctype ) ) diff --git a/erpnext/patches/v14_0/change_is_subcontracted_fieldtype.py b/erpnext/patches/v14_0/change_is_subcontracted_fieldtype.py index ba919a756a..9b07ba846f 100644 --- a/erpnext/patches/v14_0/change_is_subcontracted_fieldtype.py +++ b/erpnext/patches/v14_0/change_is_subcontracted_fieldtype.py @@ -10,7 +10,7 @@ def execute(): """ UPDATE `tab{doctype}` SET is_subcontracted = 0 - where is_subcontracted in ('', NULL, 'No')""".format( + where is_subcontracted in ('', 'No') or is_subcontracted is null""".format( doctype=doctype ) ) From bce1c2a0284dcdb498b01d1e726522f9b535cc9b Mon Sep 17 00:00:00 2001 From: Jannat Patel Date: Wed, 6 Apr 2022 14:44:10 +0530 Subject: [PATCH 49/76] fix: removed unused courses template --- erpnext/templates/pages/courses.html | 11 ----------- erpnext/templates/pages/courses.py | 18 ------------------ 2 files changed, 29 deletions(-) delete mode 100644 erpnext/templates/pages/courses.html delete mode 100644 erpnext/templates/pages/courses.py diff --git a/erpnext/templates/pages/courses.html b/erpnext/templates/pages/courses.html deleted file mode 100644 index 6592f7a2e5..0000000000 --- a/erpnext/templates/pages/courses.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "templates/web.html" %} - -{% block header %} -

About

-{% endblock %} - -{% block page_content %} - -

{{ intro }}

- -{% endblock %} diff --git a/erpnext/templates/pages/courses.py b/erpnext/templates/pages/courses.py deleted file mode 100644 index fb1af387d2..0000000000 --- a/erpnext/templates/pages/courses.py +++ /dev/null @@ -1,18 +0,0 @@ -# Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors -# License: GNU General Public License v3. See license.txt - - -import frappe - - -def get_context(context): - course = frappe.get_doc("Course", frappe.form_dict.course) - sidebar_title = course.name - - context.no_cache = 1 - context.show_sidebar = True - course = frappe.get_doc("Course", frappe.form_dict.course) - course.has_permission("read") - context.doc = course - context.sidebar_title = sidebar_title - context.intro = course.course_intro From ba42c876879d34549aaefdf4f620bd90b2b05c2b Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 6 Apr 2022 15:40:41 +0530 Subject: [PATCH 50/76] refactor: don't use pandas for basic reports (#30597) --- .../opportunity_summary_by_sales_stage.py | 17 +++--- .../sales_pipeline_analytics.py | 15 ++--- .../selling/page/sales_funnel/sales_funnel.py | 56 ++++++++----------- 3 files changed, 37 insertions(+), 51 deletions(-) diff --git a/erpnext/crm/report/opportunity_summary_by_sales_stage/opportunity_summary_by_sales_stage.py b/erpnext/crm/report/opportunity_summary_by_sales_stage/opportunity_summary_by_sales_stage.py index 77e6ae2e04..3a46fb0879 100644 --- a/erpnext/crm/report/opportunity_summary_by_sales_stage/opportunity_summary_by_sales_stage.py +++ b/erpnext/crm/report/opportunity_summary_by_sales_stage/opportunity_summary_by_sales_stage.py @@ -1,9 +1,9 @@ # Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors # For license information, please see license.txt import json +from itertools import groupby import frappe -import pandas from frappe import _ from frappe.utils import flt @@ -101,18 +101,19 @@ class OpportunitySummaryBySalesStage(object): self.convert_to_base_currency() - dataframe = pandas.DataFrame.from_records(self.query_result) - dataframe.replace(to_replace=[None], value="Not Assigned", inplace=True) - result = dataframe.groupby(["sales_stage", based_on], as_index=False)["amount"].sum() + for row in self.query_result: + if not row.get(based_on): + row[based_on] = "Not Assigned" self.grouped_data = [] - for i in range(len(result["amount"])): + grouping_key = lambda o: (o["sales_stage"], o[based_on]) # noqa + for (sales_stage, _based_on), rows in groupby(self.query_result, grouping_key): self.grouped_data.append( { - "sales_stage": result["sales_stage"][i], - based_on: result[based_on][i], - "amount": result["amount"][i], + "sales_stage": sales_stage, + based_on: _based_on, + "amount": sum(flt(r["amount"]) for r in rows), } ) diff --git a/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py b/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py index b0c174be23..d23a22ac46 100644 --- a/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py +++ b/erpnext/crm/report/sales_pipeline_analytics/sales_pipeline_analytics.py @@ -3,9 +3,9 @@ import json from datetime import date +from itertools import groupby import frappe -import pandas from dateutil.relativedelta import relativedelta from frappe import _ from frappe.utils import cint, flt @@ -109,18 +109,15 @@ class SalesPipelineAnalytics(object): self.convert_to_base_currency() - dataframe = pandas.DataFrame.from_records(self.query_result) - dataframe.replace(to_replace=[None], value="Not Assigned", inplace=True) - result = dataframe.groupby([self.pipeline_by, self.period_by], as_index=False)["amount"].sum() - self.grouped_data = [] - for i in range(len(result["amount"])): + grouping_key = lambda o: (o.get(self.pipeline_by) or "Not Assigned", o[self.period_by]) # noqa + for (pipeline_by, period_by), rows in groupby(self.query_result, grouping_key): self.grouped_data.append( { - self.pipeline_by: result[self.pipeline_by][i], - self.period_by: result[self.period_by][i], - "amount": result["amount"][i], + self.pipeline_by: pipeline_by, + self.period_by: period_by, + "amount": sum(flt(r["amount"]) for r in rows), } ) diff --git a/erpnext/selling/page/sales_funnel/sales_funnel.py b/erpnext/selling/page/sales_funnel/sales_funnel.py index c626f5b05f..6b33a71753 100644 --- a/erpnext/selling/page/sales_funnel/sales_funnel.py +++ b/erpnext/selling/page/sales_funnel/sales_funnel.py @@ -1,10 +1,11 @@ # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors # License: GNU General Public License v3. See license.txt +from itertools import groupby import frappe -import pandas as pd from frappe import _ +from frappe.utils import flt from erpnext.accounts.report.utils import convert @@ -89,28 +90,21 @@ def get_opp_by_lead_source(from_date, to_date, company): for x in opportunities ] - df = ( - pd.DataFrame(cp_opportunities) - .groupby(["source", "sales_stage"], as_index=False) - .agg({"compound_amount": "sum"}) - ) + summary = {} + sales_stages = set() + group_key = lambda o: (o["source"], o["sales_stage"]) # noqa + for (source, sales_stage), rows in groupby(cp_opportunities, group_key): + summary.setdefault(source, {})[sales_stage] = sum(r["compound_amount"] for r in rows) + sales_stages.add(sales_stage) - result = {} - result["labels"] = list(set(df.source.values)) - result["datasets"] = [] - - for s in set(df.sales_stage.values): - result["datasets"].append( - {"name": s, "values": [0] * len(result["labels"]), "chartType": "bar"} - ) - - for row in df.itertuples(): - source_index = result["labels"].index(row.source) - - for dataset in result["datasets"]: - if dataset["name"] == row.sales_stage: - dataset["values"][source_index] = row.compound_amount + pivot_table = [] + for sales_stage in sales_stages: + row = [] + for source, sales_stage_values in summary.items(): + row.append(flt(sales_stage_values.get(sales_stage))) + pivot_table.append({"chartType": "bar", "name": sales_stage, "values": row}) + result = {"datasets": pivot_table, "labels": list(summary.keys())} return result else: @@ -148,20 +142,14 @@ def get_pipeline_data(from_date, to_date, company): for x in opportunities ] - df = ( - pd.DataFrame(cp_opportunities) - .groupby(["sales_stage"], as_index=True) - .agg({"compound_amount": "sum"}) - .to_dict() - ) - - result = {} - result["labels"] = df["compound_amount"].keys() - result["datasets"] = [] - result["datasets"].append( - {"name": _("Total Amount"), "values": df["compound_amount"].values(), "chartType": "bar"} - ) + summary = {} + for sales_stage, rows in groupby(cp_opportunities, lambda o: o["sales_stage"]): + summary[sales_stage] = sum(flt(r["compound_amount"]) for r in rows) + result = { + "labels": list(summary.keys()), + "datasets": [{"name": _("Total Amount"), "values": list(summary.values()), "chartType": "bar"}], + } return result else: From 8b090a9f7d18aa25ae0568c2e52395812eb32462 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 6 Apr 2022 16:35:36 +0530 Subject: [PATCH 51/76] fix: hide pending qty only if original item is assigned (#30599) --- erpnext/public/js/utils/serial_no_batch_selector.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/public/js/utils/serial_no_batch_selector.js b/erpnext/public/js/utils/serial_no_batch_selector.js index f484545983..64c5ee59dc 100644 --- a/erpnext/public/js/utils/serial_no_batch_selector.js +++ b/erpnext/public/js/utils/serial_no_batch_selector.js @@ -609,8 +609,8 @@ function check_can_calculate_pending_qty(me) { && erpnext.stock.bom && erpnext.stock.bom.name === doc.bom_no; const itemChecks = !!item - && !item.allow_alternative_item - && erpnext.stock.bom && erpnext.stock.items + && !item.original_item + && erpnext.stock.bom && erpnext.stock.bom.items && (item.item_code in erpnext.stock.bom.items); return docChecks && itemChecks; } From eebcf2a9f1871682e3201f79420b9972c78161fe Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 6 Apr 2022 18:13:55 +0530 Subject: [PATCH 52/76] fix: only trigger onload checks on saved docs (#30603) --- erpnext/manufacturing/doctype/job_card/job_card.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index d85b8a60d2..9e30af703b 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -28,12 +28,12 @@ frappe.ui.form.on('Job Card', { frappe.flags.resume_job = 0; let has_items = frm.doc.items && frm.doc.items.length; - if (frm.doc.__onload.work_order_closed) { + if (!frm.is_new() && frm.doc.__onload.work_order_closed) { frm.disable_save(); return; } - if (!frm.doc.__islocal && has_items && frm.doc.docstatus < 2) { + if (!frm.doc.is_new() && has_items && frm.doc.docstatus < 2) { let to_request = frm.doc.for_quantity > frm.doc.transferred_qty; let excess_transfer_allowed = frm.doc.__onload.job_card_excess_transfer; From 9e2d54f9a4f0d27e91e5c89f5d3434b7b8a430e0 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 6 Apr 2022 18:20:46 +0530 Subject: [PATCH 53/76] chore: broken translation [skip ci] --- erpnext/translations/fr.csv | 1 - 1 file changed, 1 deletion(-) diff --git a/erpnext/translations/fr.csv b/erpnext/translations/fr.csv index ea01ec63e4..6da2a799a6 100644 --- a/erpnext/translations/fr.csv +++ b/erpnext/translations/fr.csv @@ -9855,7 +9855,6 @@ Stock Transactions Settings, Paramétre des transactions Role Allowed to Over Deliver/Receive, Rôle autorisé à dépasser cette limite Users with this role are allowed to over deliver/receive against orders above the allowance percentage,Rôle Utilisateur qui sont autorisé à livrée/commandé au-delà de la limite Over Transfer Allowance,Autorisation de limite de transfert -The percentage you are allowed to transfer more against the quantity ordered. For example, if you have ordered 100 units, and your Allowance is 10%, then you are allowed transfer 110 units, Le pourcentage de quantité que vous pourrez receptionné en plus de la quantité commandée Quality Inspection Settings,Paramétre de l'inspection qualité Action If Quality Inspection Is Rejected,Action si l'inspection qualité est rejetée Disable Serial No And Batch Selector,Désactiver le sélecteur de numéro de lot/série From 8e425252c413dc40bdbd07c567adf430bf8efe0e Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Wed, 6 Apr 2022 18:27:41 +0530 Subject: [PATCH 54/76] chore: typo --- erpnext/manufacturing/doctype/job_card/job_card.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/manufacturing/doctype/job_card/job_card.js b/erpnext/manufacturing/doctype/job_card/job_card.js index 9e30af703b..b2824e139c 100644 --- a/erpnext/manufacturing/doctype/job_card/job_card.js +++ b/erpnext/manufacturing/doctype/job_card/job_card.js @@ -33,7 +33,7 @@ frappe.ui.form.on('Job Card', { return; } - if (!frm.doc.is_new() && has_items && frm.doc.docstatus < 2) { + if (!frm.is_new() && has_items && frm.doc.docstatus < 2) { let to_request = frm.doc.for_quantity > frm.doc.transferred_qty; let excess_transfer_allowed = frm.doc.__onload.job_card_excess_transfer; From 8feb4f08c5c96f0ff5575e13931b07e2f67a25e4 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Wed, 6 Apr 2022 20:51:51 +0530 Subject: [PATCH 55/76] fix: Exchange gain and loss button in Payment Entry --- erpnext/accounts/doctype/payment_entry/payment_entry.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 7315ae8936..403e2bdfe7 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -224,10 +224,7 @@ frappe.ui.form.on('Payment Entry', { (frm.doc.total_allocated_amount > party_amount))); frm.toggle_display("set_exchange_gain_loss", - (frm.doc.paid_amount && frm.doc.received_amount && frm.doc.difference_amount && - ((frm.doc.paid_from_account_currency != company_currency || - frm.doc.paid_to_account_currency != company_currency) && - frm.doc.paid_from_account_currency != frm.doc.paid_to_account_currency))); + frm.doc.paid_amount && frm.doc.received_amount && frm.doc.difference_amount); frm.refresh_fields(); }, From e4c6d6a1a6b9d9ed9aa01d9f3290546bfa76e4bb Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Thu, 7 Apr 2022 12:53:10 +0530 Subject: [PATCH 56/76] fix: strip html tags before checking for empty description (#30619) --- erpnext/stock/doctype/item/item.py | 7 ++----- erpnext/stock/doctype/item/test_item.py | 7 +++++++ 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index 535f565209..5fdecc9895 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -18,6 +18,7 @@ from frappe.utils import ( now_datetime, nowtime, strip, + strip_html, ) from frappe.utils.html_utils import clean_html @@ -69,10 +70,6 @@ class Item(Document): self.item_code = strip(self.item_code) self.name = self.item_code - def before_insert(self): - if not self.description: - self.description = self.item_name - def after_insert(self): """set opening stock and item price""" if self.standard_rate: @@ -86,7 +83,7 @@ class Item(Document): if not self.item_name: self.item_name = self.item_code - if not self.description: + if not strip_html(cstr(self.description)).strip(): self.description = self.item_name self.validate_uom() diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 328d937f31..8dd35d769f 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -744,6 +744,13 @@ class TestItem(FrappeTestCase): self.assertTrue(get_data(warehouse="_Test Warehouse - _TC")) self.assertTrue(get_data(item_group="All Item Groups")) + def test_empty_description(self): + item = make_item(properties={"description": "

"}) + self.assertEqual(item.description, item.item_name) + item.description = "" + item.save() + self.assertEqual(item.description, item.item_name) + def set_item_variant_settings(fields): doc = frappe.get_doc("Item Variant Settings") From f8f1c3d8b57d95f799696de436f8a0d0c1b0e714 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 7 Apr 2022 10:06:08 +0530 Subject: [PATCH 57/76] fix: enable Track Changes in Leave Allocation --- erpnext/hr/doctype/leave_allocation/leave_allocation.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/erpnext/hr/doctype/leave_allocation/leave_allocation.json b/erpnext/hr/doctype/leave_allocation/leave_allocation.json index 9ecbe014b9..9d1db9b17f 100644 --- a/erpnext/hr/doctype/leave_allocation/leave_allocation.json +++ b/erpnext/hr/doctype/leave_allocation/leave_allocation.json @@ -237,7 +237,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2022-01-18 19:15:53.262536", + "modified": "2022-04-07 09:50:33.145825", "modified_by": "Administrator", "module": "HR", "name": "Leave Allocation", @@ -281,5 +281,6 @@ "sort_order": "DESC", "states": [], "timeline_field": "employee", - "title_field": "employee_name" + "title_field": "employee_name", + "track_changes": 1 } \ No newline at end of file From 6203ffc8fab9a6061b991fa689c73391d1671cdf Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 7 Apr 2022 10:06:51 +0530 Subject: [PATCH 58/76] fix: make New Leaves Allocated read-only if policy assignment is linked to the allocation and leave type is earned leave --- erpnext/hr/doctype/leave_allocation/leave_allocation.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/erpnext/hr/doctype/leave_allocation/leave_allocation.js b/erpnext/hr/doctype/leave_allocation/leave_allocation.js index 9742387c16..aef4412251 100755 --- a/erpnext/hr/doctype/leave_allocation/leave_allocation.js +++ b/erpnext/hr/doctype/leave_allocation/leave_allocation.js @@ -34,6 +34,15 @@ frappe.ui.form.on("Leave Allocation", { }); } } + + // make new leaves allocated field read only if allocation is created via leave policy assignment + // and leave type is earned leave, since these leaves would be allocated via the scheduler + if (frm.doc.leave_policy_assignment) { + frappe.db.get_value("Leave Type", frm.doc.leave_type, "is_earned_leave", (r) => { + if (r && cint(r.is_earned_leave)) + frm.set_df_property("new_leaves_allocated", "read_only", 1); + }); + } }, expire_allocation: function(frm) { From ec65af5f38bc860701fc8a15f67212cbb4b357d2 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Thu, 7 Apr 2022 10:07:39 +0530 Subject: [PATCH 59/76] fix: show allocation history for earned leaves allocated via scheduler --- erpnext/hr/utils.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/erpnext/hr/utils.py b/erpnext/hr/utils.py index fd69a9b4f1..40ab8053c9 100644 --- a/erpnext/hr/utils.py +++ b/erpnext/hr/utils.py @@ -353,6 +353,17 @@ def update_previous_leave_allocation( allocation.db_set("total_leaves_allocated", new_allocation, update_modified=False) create_additional_leave_ledger_entry(allocation, earned_leaves, today_date) + if e_leave_type.based_on_date_of_joining: + text = _("allocated {0} leave(s) via scheduler on {1} based on the date of joining").format( + frappe.bold(earned_leaves), frappe.bold(formatdate(today_date)) + ) + else: + text = _("allocated {0} leave(s) via scheduler on {1}").format( + frappe.bold(earned_leaves), frappe.bold(formatdate(today_date)) + ) + + allocation.add_comment(comment_type="Info", text=text) + def get_monthly_earned_leave(annual_leaves, frequency, rounding): earned_leaves = 0.0 From a6e26cce87d1c9d5915f098573d399ce1399ccb1 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Thu, 7 Apr 2022 13:07:14 +0530 Subject: [PATCH 60/76] chore: broken translations --- erpnext/translations/fr.csv | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/translations/fr.csv b/erpnext/translations/fr.csv index 6da2a799a6..db454def73 100644 --- a/erpnext/translations/fr.csv +++ b/erpnext/translations/fr.csv @@ -9864,7 +9864,7 @@ Control Historical Stock Transactions,Controle de l'historique des stransact No stock transactions can be created or modified before this date.,Aucune transaction ne peux être créée ou modifié avant cette date. Stock transactions that are older than the mentioned days cannot be modified.,Les transactions de stock plus ancienne que le nombre de jours ci-dessus ne peuvent être modifiées Role Allowed to Create/Edit Back-dated Transactions,Rôle autorisé à créer et modifier des transactions anti-datée -If mentioned, the system will allow only the users with this Role to create or modify any stock transaction earlier than the latest stock transaction for a specific item and warehouse. If set as blank, it allows all users to create/edit back-dated transactions.,LEs utilisateur de ce role pourront creer et modifier des transactions dans le passé. Si vide tout les utilisateurs pourrons le faire +"If mentioned, the system will allow only the users with this Role to create or modify any stock transaction earlier than the latest stock transaction for a specific item and warehouse. If set as blank, it allows all users to create/edit back-dated transactions.","LEs utilisateur de ce role pourront creer et modifier des transactions dans le passé. Si vide tout les utilisateurs pourrons le faire" Auto Insert Item Price If Missing,Création du prix de l'article dans les listes de prix si abscent Update Existing Price List Rate,Mise a jour automatique du prix dans les listes de prix Show Barcode Field in Stock Transactions,Afficher le champ Code Barre dans les transactions de stock From 22e8ae9dac1bbe83806b27fcc998765f263075ef Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Thu, 7 Apr 2022 13:20:53 +0530 Subject: [PATCH 61/76] test: Pricing rule test for transactions --- erpnext/accounts/doctype/pricing_rule/pricing_rule.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py index c45b069730..2438f4b1ab 100644 --- a/erpnext/accounts/doctype/pricing_rule/pricing_rule.py +++ b/erpnext/accounts/doctype/pricing_rule/pricing_rule.py @@ -35,10 +35,11 @@ class PricingRule(Document): self.margin_rate_or_amount = 0.0 def validate_duplicate_apply_on(self): - field = apply_on_dict.get(self.apply_on) - values = [d.get(frappe.scrub(self.apply_on)) for d in self.get(field) if field] - if len(values) != len(set(values)): - frappe.throw(_("Duplicate {0} found in the table").format(self.apply_on)) + if self.apply_on != "Transaction": + field = apply_on_dict.get(self.apply_on) + values = [d.get(frappe.scrub(self.apply_on)) for d in self.get(field) if field] + if len(values) != len(set(values)): + frappe.throw(_("Duplicate {0} found in the table").format(self.apply_on)) def validate_mandatory(self): for apply_on, field in apply_on_dict.items(): From be04eaf723804e72226462472884c38b0c0d26ff Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Thu, 7 Apr 2022 13:21:08 +0530 Subject: [PATCH 62/76] fix: warehouse naming when suffix is present (#30621) --- erpnext/stock/doctype/warehouse/test_warehouse.py | 10 ++++++++++ erpnext/stock/doctype/warehouse/warehouse.py | 5 +++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/warehouse/test_warehouse.py b/erpnext/stock/doctype/warehouse/test_warehouse.py index 1e9d01aa4b..5a7228a506 100644 --- a/erpnext/stock/doctype/warehouse/test_warehouse.py +++ b/erpnext/stock/doctype/warehouse/test_warehouse.py @@ -38,6 +38,16 @@ class TestWarehouse(FrappeTestCase): self.assertEqual(p_warehouse.name, child_warehouse.parent_warehouse) self.assertEqual(child_warehouse.is_group, 0) + def test_naming(self): + company = "Wind Power LLC" + warehouse_name = "Named Warehouse - WP" + wh = frappe.get_doc(doctype="Warehouse", warehouse_name=warehouse_name, company=company).insert() + self.assertEqual(wh.name, warehouse_name) + + warehouse_name = "Unnamed Warehouse" + wh = frappe.get_doc(doctype="Warehouse", warehouse_name=warehouse_name, company=company).insert() + self.assertIn(warehouse_name, wh.name) + def test_unlinking_warehouse_from_item_defaults(self): company = "_Test Company" diff --git a/erpnext/stock/doctype/warehouse/warehouse.py b/erpnext/stock/doctype/warehouse/warehouse.py index c892ba3ddc..3b18a9ac26 100644 --- a/erpnext/stock/doctype/warehouse/warehouse.py +++ b/erpnext/stock/doctype/warehouse/warehouse.py @@ -21,8 +21,9 @@ class Warehouse(NestedSet): suffix = " - " + frappe.get_cached_value("Company", self.company, "abbr") if not self.warehouse_name.endswith(suffix): self.name = self.warehouse_name + suffix - else: - self.name = self.warehouse_name + return + + self.name = self.warehouse_name def onload(self): """load account name for General Ledger Report""" From fa4f57f470017f1bc762b4612bf289bd707aed25 Mon Sep 17 00:00:00 2001 From: Sagar Vora Date: Thu, 7 Apr 2022 16:51:27 +0530 Subject: [PATCH 63/76] fix: dont reassign mutable (list) to a different field --- .../report/purchase_register/purchase_register.py | 10 +++++----- erpnext/crm/doctype/opportunity/opportunity.py | 3 ++- erpnext/selling/doctype/customer/customer.py | 3 ++- erpnext/selling/doctype/customer/test_customer.py | 3 ++- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/erpnext/accounts/report/purchase_register/purchase_register.py b/erpnext/accounts/report/purchase_register/purchase_register.py index c359959310..ed63e7f1d2 100644 --- a/erpnext/accounts/report/purchase_register/purchase_register.py +++ b/erpnext/accounts/report/purchase_register/purchase_register.py @@ -124,11 +124,11 @@ def get_columns(invoice_list, additional_table_columns): _("Purchase Receipt") + ":Link/Purchase Receipt:100", {"fieldname": "currency", "label": _("Currency"), "fieldtype": "Data", "width": 80}, ] - expense_accounts = ( - tax_accounts - ) = ( - expense_columns - ) = tax_columns = unrealized_profit_loss_accounts = unrealized_profit_loss_account_columns = [] + + expense_accounts = [] + tax_accounts = [] + tax_columns = [] + unrealized_profit_loss_accounts = [] if invoice_list: expense_accounts = frappe.db.sql_list( diff --git a/erpnext/crm/doctype/opportunity/opportunity.py b/erpnext/crm/doctype/opportunity/opportunity.py index 03ff2691e7..96c730c668 100644 --- a/erpnext/crm/doctype/opportunity/opportunity.py +++ b/erpnext/crm/doctype/opportunity/opportunity.py @@ -126,7 +126,8 @@ class Opportunity(TransactionBase): def declare_enquiry_lost(self, lost_reasons_list, competitors, detailed_reason=None): if not self.has_active_quotation(): self.status = "Lost" - self.lost_reasons = self.competitors = [] + self.lost_reasons = [] + self.competitors = [] if detailed_reason: self.order_lost_reason = detailed_reason diff --git a/erpnext/selling/doctype/customer/customer.py b/erpnext/selling/doctype/customer/customer.py index 2e5cbb80cb..8889a5f939 100644 --- a/erpnext/selling/doctype/customer/customer.py +++ b/erpnext/selling/doctype/customer/customer.py @@ -100,7 +100,8 @@ class Customer(TransactionBase): @frappe.whitelist() def get_customer_group_details(self): doc = frappe.get_doc("Customer Group", self.customer_group) - self.accounts = self.credit_limits = [] + self.accounts = [] + self.credit_limits = [] self.payment_terms = self.default_price_list = "" tables = [["accounts", "account"], ["credit_limits", "credit_limit"]] diff --git a/erpnext/selling/doctype/customer/test_customer.py b/erpnext/selling/doctype/customer/test_customer.py index 4027d2ee14..36ca2b2fdc 100644 --- a/erpnext/selling/doctype/customer/test_customer.py +++ b/erpnext/selling/doctype/customer/test_customer.py @@ -45,7 +45,8 @@ class TestCustomer(FrappeTestCase): c_doc.customer_name = "Testing Customer" c_doc.customer_group = "_Testing Customer Group" c_doc.payment_terms = c_doc.default_price_list = "" - c_doc.accounts = c_doc.credit_limits = [] + c_doc.accounts = [] + c_doc.credit_limits = [] c_doc.insert() c_doc.get_customer_group_details() self.assertEqual(c_doc.payment_terms, "_Test Payment Term Template 3") From 3b4754f3f68a41bd7b5c5fb46e838a8700ce057a Mon Sep 17 00:00:00 2001 From: Sagar Vora Date: Thu, 7 Apr 2022 17:01:10 +0530 Subject: [PATCH 64/76] fix: define tax_columns below consistency --- .../report/purchase_register/purchase_register.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/erpnext/accounts/report/purchase_register/purchase_register.py b/erpnext/accounts/report/purchase_register/purchase_register.py index ed63e7f1d2..a73c72c6d8 100644 --- a/erpnext/accounts/report/purchase_register/purchase_register.py +++ b/erpnext/accounts/report/purchase_register/purchase_register.py @@ -127,7 +127,6 @@ def get_columns(invoice_list, additional_table_columns): expense_accounts = [] tax_accounts = [] - tax_columns = [] unrealized_profit_loss_accounts = [] if invoice_list: @@ -163,10 +162,11 @@ def get_columns(invoice_list, additional_table_columns): unrealized_profit_loss_account_columns = [ (account + ":Currency/currency:120") for account in unrealized_profit_loss_accounts ] - - for account in tax_accounts: - if account not in expense_accounts: - tax_columns.append(account + ":Currency/currency:120") + tax_columns = [ + (account + ":Currency/currency:120") + for account in tax_accounts + if account not in expense_accounts + ] columns = ( columns From 2f1e98d1e81a8d154067c6c1d0fd0e8e4f920dc2 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Thu, 7 Apr 2022 15:40:35 +0530 Subject: [PATCH 65/76] feat: show Stock Reconciliation links on item dashboard --- erpnext/stock/doctype/item/item_dashboard.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/item/item_dashboard.py b/erpnext/stock/doctype/item/item_dashboard.py index 3caed02d69..897acb7448 100644 --- a/erpnext/stock/doctype/item/item_dashboard.py +++ b/erpnext/stock/doctype/item/item_dashboard.py @@ -31,7 +31,7 @@ def get_data(): }, {"label": _("Manufacture"), "items": ["Production Plan", "Work Order", "Item Manufacturer"]}, {"label": _("Traceability"), "items": ["Serial No", "Batch"]}, - {"label": _("Move"), "items": ["Stock Entry"]}, + {"label": _("Stock Movement"), "items": ["Stock Entry", "Stock Reconciliation"]}, {"label": _("E-commerce"), "items": ["Website Item"]}, ], } From fba68541d6abb07f65ea1e37d68422a17d032a3e Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Thu, 7 Apr 2022 15:52:19 +0530 Subject: [PATCH 66/76] refactor(Item): linked doc checking --- erpnext/stock/doctype/item/item.py | 59 +++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 18 deletions(-) diff --git a/erpnext/stock/doctype/item/item.py b/erpnext/stock/doctype/item/item.py index 5fdecc9895..b2f5fb7d20 100644 --- a/erpnext/stock/doctype/item/item.py +++ b/erpnext/stock/doctype/item/item.py @@ -3,7 +3,7 @@ import copy import json -from typing import List +from typing import Dict, List, Optional import frappe from frappe import _ @@ -887,25 +887,38 @@ class Item(Document): if self.is_new(): return - fields = ("has_serial_no", "is_stock_item", "valuation_method", "has_batch_no") + restricted_fields = ("has_serial_no", "is_stock_item", "valuation_method", "has_batch_no") + + values = frappe.db.get_value("Item", self.name, restricted_fields, as_dict=True) + if not values: + return - values = frappe.db.get_value("Item", self.name, fields, as_dict=True) if not values.get("valuation_method") and self.get("valuation_method"): values["valuation_method"] = ( frappe.db.get_single_value("Stock Settings", "valuation_method") or "FIFO" ) - if values: - for field in fields: - if cstr(self.get(field)) != cstr(values.get(field)): - if self.check_if_linked_document_exists(field): - frappe.throw( - _( - "As there are existing transactions against item {0}, you can not change the value of {1}" - ).format(self.name, frappe.bold(self.meta.get_label(field))) - ) + changed_fields = [ + field for field in restricted_fields if cstr(self.get(field)) != cstr(values.get(field)) + ] + if not changed_fields: + return - def check_if_linked_document_exists(self, field): + if linked_doc := self._get_linked_submitted_documents(changed_fields): + changed_field_labels = [frappe.bold(self.meta.get_label(f)) for f in changed_fields] + msg = _( + "As there are existing submitted transactions against item {0}, you can not change the value of {1}." + ).format(self.name, ", ".join(changed_field_labels)) + + if linked_doc and isinstance(linked_doc, dict): + msg += "
" + msg += _("Example of a linked document: {0}").format( + frappe.get_desk_link(linked_doc.doctype, linked_doc.docname) + ) + + frappe.throw(msg, title=_("Linked with submitted documents")) + + def _get_linked_submitted_documents(self, changed_fields: List[str]) -> Optional[Dict[str, str]]: linked_doctypes = [ "Delivery Note Item", "Sales Invoice Item", @@ -918,7 +931,7 @@ class Item(Document): # For "Is Stock Item", following doctypes is important # because reserved_qty, ordered_qty and requested_qty updated from these doctypes - if field == "is_stock_item": + if "is_stock_item" in changed_fields: linked_doctypes += [ "Sales Order Item", "Purchase Order Item", @@ -937,11 +950,21 @@ class Item(Document): "Sales Invoice Item", ): # If Invoice has Stock impact, only then consider it. - if self.stock_ledger_created(): - return True + if linked_doc := frappe.db.get_value( + "Stock Ledger Entry", + {"item_code": self.name, "is_cancelled": 0}, + ["voucher_no as docname", "voucher_type as doctype"], + as_dict=True, + ): + return linked_doc - elif frappe.db.get_value(doctype, filters): - return True + elif linked_doc := frappe.db.get_value( + doctype, + filters, + ["parent as docname", "parenttype as doctype"], + as_dict=True, + ): + return linked_doc def validate_auto_reorder_enabled_in_stock_settings(self): if self.reorder_levels: From f1fd4e5c2781923edbf6b24b95b5cd4d6bd8e79f Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Thu, 7 Apr 2022 16:59:40 +0530 Subject: [PATCH 67/76] test: cant_change validations on item --- erpnext/stock/doctype/item/test_item.py | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/erpnext/stock/doctype/item/test_item.py b/erpnext/stock/doctype/item/test_item.py index 8dd35d769f..aa0a5490b6 100644 --- a/erpnext/stock/doctype/item/test_item.py +++ b/erpnext/stock/doctype/item/test_item.py @@ -751,6 +751,33 @@ class TestItem(FrappeTestCase): item.save() self.assertEqual(item.description, item.item_name) + def test_item_type_field_change(self): + """Check if critical fields like `is_stock_item`, `has_batch_no` are not changed if transactions exist.""" + from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice + from erpnext.stock.doctype.delivery_note.test_delivery_note import create_delivery_note + from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt + from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry + + transaction_creators = [ + lambda i: make_purchase_receipt(item_code=i), + lambda i: make_purchase_invoice(item_code=i, update_stock=1), + lambda i: make_stock_entry(item_code=i, qty=1, target="_Test Warehouse - _TC"), + lambda i: create_delivery_note(item_code=i), + ] + + properties = {"has_batch_no": 0, "allow_negative_stock": 1, "valuation_rate": 10} + for transaction_creator in transaction_creators: + item = make_item(properties=properties) + transaction = transaction_creator(item.name) + item.has_batch_no = 1 + self.assertRaises(frappe.ValidationError, item.save) + + transaction.cancel() + # should be allowed now + item.reload() + item.has_batch_no = 1 + item.save() + def set_item_variant_settings(fields): doc = frappe.get_doc("Item Variant Settings") From bc2c6018f70f813770126aef8474f63187a9dd63 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Thu, 7 Apr 2022 17:53:01 +0530 Subject: [PATCH 68/76] ci: mergify auto merge on passing CI + approvals --- .mergify.yml | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.mergify.yml b/.mergify.yml index 315d90febc..cc8c0802f1 100644 --- a/.mergify.yml +++ b/.mergify.yml @@ -88,3 +88,37 @@ pull_request_rules: - version-12-pre-release assignees: - "{{ author }}" + + - name: Automatic merge on CI success and review + conditions: + - status-success=linters + - status-success=Sider + - status-success=Semantic Pull Request + - status-success=Patch Test + - status-success=Python Unit Tests (1) + - status-success=Python Unit Tests (2) + - status-success=Python Unit Tests (3) + - label!=dont-merge + - label!=squash + - "#approved-reviews-by>=1" + actions: + merge: + method: merge + - name: Automatic squash on CI success and review + conditions: + - status-success=linters + - status-success=Sider + - status-success=Patch Test + - status-success=Python Unit Tests (1) + - status-success=Python Unit Tests (2) + - status-success=Python Unit Tests (3) + - label!=dont-merge + - label=squash + - "#approved-reviews-by>=1" + actions: + merge: + method: squash + commit_message_template: | + {{ title }} (#{{ number }}) + + {{ body }} From 8fbfba4f2fc5b128173f2236c32a47563501d503 Mon Sep 17 00:00:00 2001 From: Sam <6969182+sam-anam1@users.noreply.github.com> Date: Thu, 7 Apr 2022 16:57:27 +0300 Subject: [PATCH 69/76] chore: class selectors for customizability (#30185) * Update project_row.html Really helpful those fields has own class when styling for mobile or desktop by using Website Settings --> HTML Header, Robots and Redirects * chore: skewercase --- erpnext/templates/includes/projects/project_row.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/erpnext/templates/includes/projects/project_row.html b/erpnext/templates/includes/projects/project_row.html index a256fbd677..686637a201 100644 --- a/erpnext/templates/includes/projects/project_row.html +++ b/erpnext/templates/includes/projects/project_row.html @@ -1,11 +1,11 @@ {% if doc.status == "Open" %}
-
+ -
+
{{ doc.project_name }}
@@ -25,7 +25,7 @@
{% if doc["_assign"] %} {% set assigned_users = json.loads(doc["_assign"])%} -
+
{% for user in assigned_users %} {% set user_details = frappe .db @@ -46,7 +46,7 @@ {% endfor %}
{% endif %} -
+
{{ frappe.utils.pretty_date(doc.modified) }}
From 225deb949bacc5e689a9ea88054223f241835069 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Thu, 7 Apr 2022 21:38:18 +0530 Subject: [PATCH 70/76] fix: use empty row when batch is scanned (#30638) --- erpnext/public/js/utils/barcode_scanner.js | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/erpnext/public/js/utils/barcode_scanner.js b/erpnext/public/js/utils/barcode_scanner.js index 80a463f85c..f72b85c0f6 100644 --- a/erpnext/public/js/utils/barcode_scanner.js +++ b/erpnext/public/js/utils/barcode_scanner.js @@ -68,7 +68,7 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { row = this.get_batch_row_to_modify(batch_no); } else { // serial or barcode scan - row = this.get_row_to_modify_on_scan(row, item_code); + row = this.get_row_to_modify_on_scan(item_code); } if (!row) { @@ -177,21 +177,17 @@ erpnext.utils.BarcodeScanner = class BarcodeScanner { get_batch_row_to_modify(batch_no) { // get row if batch already exists in table const existing_batch_row = this.items_table.find((d) => d.batch_no === batch_no); - return existing_batch_row || null; + return existing_batch_row || this.get_existing_blank_row(); } - get_row_to_modify_on_scan(row_to_modify, item_code) { + get_row_to_modify_on_scan(item_code) { // get an existing item row to increment or blank row to modify const existing_item_row = this.items_table.find((d) => d.item_code === item_code); - const blank_item_row = this.items_table.find((d) => !d.item_code); + return existing_item_row || this.get_existing_blank_row(); + } - if (existing_item_row) { - row_to_modify = existing_item_row; - } else if (blank_item_row) { - row_to_modify = blank_item_row; - } - - return row_to_modify; + get_existing_blank_row() { + return this.items_table.find((d) => !d.item_code); } clean_up() { From 8d57c853f380620208e624756b1913cc9797327b Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Thu, 7 Apr 2022 21:40:03 +0530 Subject: [PATCH 71/76] feat: barcode scanning in quotation (#30637) --- erpnext/selling/doctype/quotation/quotation.json | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/erpnext/selling/doctype/quotation/quotation.json b/erpnext/selling/doctype/quotation/quotation.json index ee5b0ea760..0318b704af 100644 --- a/erpnext/selling/doctype/quotation/quotation.json +++ b/erpnext/selling/doctype/quotation/quotation.json @@ -41,6 +41,8 @@ "price_list_currency", "plc_conversion_rate", "ignore_pricing_rule", + "section_break_33", + "scan_barcode", "items_section", "items", "bundle_items_section", @@ -955,13 +957,23 @@ "label": "Competitors", "options": "Competitor Detail", "read_only": 1 + }, + { + "fieldname": "section_break_33", + "fieldtype": "Section Break" + }, + { + "fieldname": "scan_barcode", + "fieldtype": "Data", + "label": "Scan Barcode", + "options": "Barcode" } ], "icon": "fa fa-shopping-cart", "idx": 82, "is_submittable": 1, "links": [], - "modified": "2021-11-30 01:33:21.106073", + "modified": "2022-04-07 11:01:31.157084", "modified_by": "Administrator", "module": "Selling", "name": "Quotation", @@ -1056,6 +1068,7 @@ "show_name_in_global_search": 1, "sort_field": "modified", "sort_order": "DESC", + "states": [], "timeline_field": "party_name", "title_field": "title" } \ No newline at end of file From 49560d20bc98470f46e24a1566122df19eba1161 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 8 Apr 2022 11:23:02 +0530 Subject: [PATCH 72/76] fix: remove bad defaults from BOM operation (#30644) [skip ci] --- erpnext/manufacturing/doctype/bom_operation/bom_operation.json | 3 +-- erpnext/stock/doctype/item/item.json | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json index 341f9692c4..b965a435bf 100644 --- a/erpnext/manufacturing/doctype/bom_operation/bom_operation.json +++ b/erpnext/manufacturing/doctype/bom_operation/bom_operation.json @@ -109,7 +109,6 @@ "read_only": 1 }, { - "default": "5", "depends_on": "eval:parent.doctype == 'BOM'", "fieldname": "base_operating_cost", "fieldtype": "Currency", @@ -187,7 +186,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2022-03-10 06:19:08.462027", + "modified": "2022-04-08 01:18:33.547481", "modified_by": "Administrator", "module": "Manufacturing", "name": "BOM Operation", diff --git a/erpnext/stock/doctype/item/item.json b/erpnext/stock/doctype/item/item.json index 524c3d1423..06da8ee9c3 100644 --- a/erpnext/stock/doctype/item/item.json +++ b/erpnext/stock/doctype/item/item.json @@ -645,7 +645,6 @@ }, { "collapsible": 1, - "default": "eval:!doc.is_fixed_asset", "fieldname": "sales_details", "fieldtype": "Section Break", "label": "Sales Details", @@ -992,4 +991,4 @@ "states": [], "title_field": "item_name", "track_changes": 1 -} \ No newline at end of file +} From a281998bcb0901fa928cfd68b9da26b1ab507449 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 8 Apr 2022 13:20:25 +0530 Subject: [PATCH 73/76] fix: prevent deleting repost queue for cancelled transactions --- .../repost_item_valuation.py | 16 ++++++++++++++++ erpnext/stock/stock_ledger.py | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py index ec1d140447..54c00d1a21 100644 --- a/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/repost_item_valuation.py @@ -61,6 +61,22 @@ class RepostItemValuation(Document): repost(self) + def before_cancel(self): + self.check_pending_repost_against_cancelled_transaction() + + def check_pending_repost_against_cancelled_transaction(self): + if self.status not in ("Queued", "In Progress"): + return + + if not (self.voucher_no and self.voucher_no): + return + + transaction_status = frappe.db.get_value(self.voucher_type, self.voucher_no, "docstatus") + if transaction_status == 2: + msg = _("Cannot cancel as processing of cancelled documents is pending.") + msg += "
" + _("Please try again in an hour.") + frappe.throw(msg, title=_("Pending processing")) + @frappe.whitelist() def restart_reposting(self): self.set_status("Queued", write=False) diff --git a/erpnext/stock/stock_ledger.py b/erpnext/stock/stock_ledger.py index 3e0ddab6d3..b7fd65bda8 100644 --- a/erpnext/stock/stock_ledger.py +++ b/erpnext/stock/stock_ledger.py @@ -178,9 +178,9 @@ def validate_cancellation(args): ) if repost_entry.status == "Queued": doc = frappe.get_doc("Repost Item Valuation", repost_entry.name) + doc.status = "Skipped" doc.flags.ignore_permissions = True doc.cancel() - doc.delete() def set_as_cancel(voucher_type, voucher_no): From d74181630a34306da4cc12e29434aada4d81f8ea Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 8 Apr 2022 13:32:20 +0530 Subject: [PATCH 74/76] test: prevent cancelling RIV of cancelled voucher --- .../test_repost_item_valuation.py | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py index f3bebad5c0..55117ceb2e 100644 --- a/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py +++ b/erpnext/stock/doctype/repost_item_valuation/test_repost_item_valuation.py @@ -1,20 +1,25 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt -import unittest import frappe +from frappe.tests.utils import FrappeTestCase from frappe.utils import nowdate from erpnext.controllers.stock_controller import create_item_wise_repost_entries +from erpnext.stock.doctype.item.test_item import make_item from erpnext.stock.doctype.purchase_receipt.test_purchase_receipt import make_purchase_receipt from erpnext.stock.doctype.repost_item_valuation.repost_item_valuation import ( in_configured_timeslot, ) +from erpnext.stock.doctype.stock_entry.stock_entry_utils import make_stock_entry from erpnext.stock.utils import PendingRepostingError -class TestRepostItemValuation(unittest.TestCase): +class TestRepostItemValuation(FrappeTestCase): + def tearDown(self): + frappe.flags.dont_execute_stock_reposts = False + def test_repost_time_slot(self): repost_settings = frappe.get_doc("Stock Reposting Settings") @@ -162,3 +167,22 @@ class TestRepostItemValuation(unittest.TestCase): self.assertRaises(PendingRepostingError, stock_settings.save) riv.set_status("Skipped") + + def test_prevention_of_cancelled_transaction_riv(self): + frappe.flags.dont_execute_stock_reposts = True + + item = make_item() + warehouse = "_Test Warehouse - _TC" + old = make_stock_entry(item_code=item.name, to_warehouse=warehouse, qty=2, rate=5) + _new = make_stock_entry(item_code=item.name, to_warehouse=warehouse, qty=5, rate=10) + + old.cancel() + + riv = frappe.get_last_doc( + "Repost Item Valuation", {"voucher_type": old.doctype, "voucher_no": old.name} + ) + self.assertRaises(frappe.ValidationError, riv.cancel) + + riv.db_set("status", "Skipped") + riv.reload() + riv.cancel() # it should cancel now From fcbd25f27a9d2fe5661ab6d541f38c562439a986 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 8 Apr 2022 17:24:10 +0530 Subject: [PATCH 75/76] chore: formatting --- erpnext/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/__init__.py b/erpnext/__init__.py index 932e190c9b..e0f0c98e9c 100644 --- a/erpnext/__init__.py +++ b/erpnext/__init__.py @@ -2,7 +2,8 @@ import inspect import frappe -__version__ = '14.0.0-dev' +__version__ = "14.0.0-dev" + def get_default_company(user=None): """Get default company for user""" From af6b07f9b95937047f6ece78db20009527805bbc Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 8 Apr 2022 18:05:04 +0530 Subject: [PATCH 76/76] fix: block cancellation of SL/GL entries (#30652) Individual GL/SLEs aren't supposed to be cancelled by users. --- erpnext/accounts/doctype/gl_entry/gl_entry.js | 2 +- erpnext/accounts/doctype/gl_entry/gl_entry.py | 5 +++++ .../stock/doctype/stock_ledger_entry/stock_ledger_entry.js | 2 +- .../stock/doctype/stock_ledger_entry/stock_ledger_entry.py | 5 +++++ 4 files changed, 12 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/doctype/gl_entry/gl_entry.js b/erpnext/accounts/doctype/gl_entry/gl_entry.js index 491cf4d12b..4d2a513518 100644 --- a/erpnext/accounts/doctype/gl_entry/gl_entry.js +++ b/erpnext/accounts/doctype/gl_entry/gl_entry.js @@ -3,6 +3,6 @@ frappe.ui.form.on('GL Entry', { refresh: function(frm) { - + frm.page.btn_secondary.hide() } }); diff --git a/erpnext/accounts/doctype/gl_entry/gl_entry.py b/erpnext/accounts/doctype/gl_entry/gl_entry.py index aee7f0e0f9..e5fa57df7f 100644 --- a/erpnext/accounts/doctype/gl_entry/gl_entry.py +++ b/erpnext/accounts/doctype/gl_entry/gl_entry.py @@ -269,6 +269,11 @@ class GLEntry(Document): if not self.fiscal_year: self.fiscal_year = get_fiscal_year(self.posting_date, company=self.company)[0] + def on_cancel(self): + msg = _("Individual GL Entry cannot be cancelled.") + msg += "
" + _("Please cancel related transaction.") + frappe.throw(msg) + def validate_balance_type(account, adv_adj=False): if not adv_adj and account: diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.js b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.js index 42cc7e6cba..23018aa615 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.js +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.js @@ -3,6 +3,6 @@ frappe.ui.form.on('Stock Ledger Entry', { refresh: function(frm) { - + frm.page.btn_secondary.hide() } }); diff --git a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py index 5c1da420e2..329cd7da09 100644 --- a/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py +++ b/erpnext/stock/doctype/stock_ledger_entry/stock_ledger_entry.py @@ -209,6 +209,11 @@ class StockLedgerEntry(Document): msg += "
" + "
".join(authorized_users) frappe.throw(msg, BackDatedStockTransaction, title=_("Backdated Stock Entry")) + def on_cancel(self): + msg = _("Individual Stock Ledger Entry cannot be cancelled.") + msg += "
" + _("Please cancel related transaction.") + frappe.throw(msg) + def on_doctype_update(): if not frappe.db.has_index("tabStock Ledger Entry", "posting_sort_index"):