From a4043c035d4116e206e486d4eecabe897cf24630 Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Fri, 19 Nov 2021 05:27:06 +0530 Subject: [PATCH 01/77] fix: Prevent clearing of Depreciation Schedule on adding more than one Finance Book --- erpnext/assets/doctype/asset/asset.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index c0c437f8d2..7031ed15bd 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -185,11 +185,11 @@ class Asset(AccountsController): if not self.available_for_use_date: return + start = self.clear_depreciation_schedule() + for d in self.get('finance_books'): self.validate_asset_finance_books(d) - start = self.clear_depreciation_schedule() - # value_after_depreciation - current Asset value if self.docstatus == 1 and d.value_after_depreciation: value_after_depreciation = (flt(d.value_after_depreciation) - From 059d1f3b74b2bee707387cea518eeb394bfce3d8 Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Fri, 19 Nov 2021 05:35:41 +0530 Subject: [PATCH 02/77] fix: Rename loop variable --- erpnext/assets/doctype/asset/asset.py | 66 +++++++++++++-------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index 7031ed15bd..c00d6836a6 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -187,23 +187,23 @@ class Asset(AccountsController): start = self.clear_depreciation_schedule() - for d in self.get('finance_books'): - self.validate_asset_finance_books(d) + for finance_book in self.get('finance_books'): + self.validate_asset_finance_books(finance_book) # value_after_depreciation - current Asset value - if self.docstatus == 1 and d.value_after_depreciation: - value_after_depreciation = (flt(d.value_after_depreciation) - + if self.docstatus == 1 and finance_book.value_after_depreciation: + value_after_depreciation = (flt(finance_book.value_after_depreciation) - flt(self.opening_accumulated_depreciation)) else: value_after_depreciation = (flt(self.gross_purchase_amount) - flt(self.opening_accumulated_depreciation)) - d.value_after_depreciation = value_after_depreciation + finance_book.value_after_depreciation = value_after_depreciation - number_of_pending_depreciations = cint(d.total_number_of_depreciations) - \ + number_of_pending_depreciations = cint(finance_book.total_number_of_depreciations) - \ cint(self.number_of_depreciations_booked) - has_pro_rata = self.check_is_pro_rata(d) + has_pro_rata = self.check_is_pro_rata(finance_book) if has_pro_rata: number_of_pending_depreciations += 1 @@ -213,56 +213,56 @@ class Asset(AccountsController): # If depreciation is already completed (for double declining balance) if skip_row: continue - depreciation_amount = get_depreciation_amount(self, value_after_depreciation, d) + depreciation_amount = get_depreciation_amount(self, value_after_depreciation, finance_book) if not has_pro_rata or n < cint(number_of_pending_depreciations) - 1: - schedule_date = add_months(d.depreciation_start_date, - n * cint(d.frequency_of_depreciation)) + schedule_date = add_months(finance_book.depreciation_start_date, + n * cint(finance_book.frequency_of_depreciation)) # schedule date will be a year later from start date # so monthly schedule date is calculated by removing 11 months from it - monthly_schedule_date = add_months(schedule_date, - d.frequency_of_depreciation + 1) + monthly_schedule_date = add_months(schedule_date, - finance_book.frequency_of_depreciation + 1) # if asset is being sold if date_of_sale: - from_date = self.get_from_date(d.finance_book) - depreciation_amount, days, months = self.get_pro_rata_amt(d, depreciation_amount, + from_date = self.get_from_date(finance_book.finance_book) + depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount, from_date, date_of_sale) if depreciation_amount > 0: self.append("schedules", { "schedule_date": date_of_sale, "depreciation_amount": depreciation_amount, - "depreciation_method": d.depreciation_method, - "finance_book": d.finance_book, - "finance_book_id": d.idx + "depreciation_method": finance_book.depreciation_method, + "finance_book": finance_book.finance_book, + "finance_book_id": finance_book.idx }) break # For first row if has_pro_rata and n==0: - depreciation_amount, days, months = self.get_pro_rata_amt(d, depreciation_amount, - self.available_for_use_date, d.depreciation_start_date) + depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount, + self.available_for_use_date, finance_book.depreciation_start_date) # For first depr schedule date will be the start date # so monthly schedule date is calculated by removing month difference between use date and start date - monthly_schedule_date = add_months(d.depreciation_start_date, - months + 1) + monthly_schedule_date = add_months(finance_book.depreciation_start_date, - months + 1) # For last row elif has_pro_rata and n == cint(number_of_pending_depreciations) - 1: if not self.flags.increase_in_asset_life: # In case of increase_in_asset_life, the self.to_date is already set on asset_repair submission self.to_date = add_months(self.available_for_use_date, - n * cint(d.frequency_of_depreciation)) + n * cint(finance_book.frequency_of_depreciation)) depreciation_amount_without_pro_rata = depreciation_amount - depreciation_amount, days, months = self.get_pro_rata_amt(d, + depreciation_amount, days, months = self.get_pro_rata_amt(finance_book, depreciation_amount, schedule_date, self.to_date) depreciation_amount = self.get_adjusted_depreciation_amount(depreciation_amount_without_pro_rata, - depreciation_amount, d.finance_book) + depreciation_amount, finance_book.finance_book) monthly_schedule_date = add_months(schedule_date, 1) schedule_date = add_days(schedule_date, days) @@ -273,10 +273,10 @@ class Asset(AccountsController): self.precision("gross_purchase_amount")) # Adjust depreciation amount in the last period based on the expected value after useful life - if d.expected_value_after_useful_life and ((n == cint(number_of_pending_depreciations) - 1 - and value_after_depreciation != d.expected_value_after_useful_life) - or value_after_depreciation < d.expected_value_after_useful_life): - depreciation_amount += (value_after_depreciation - d.expected_value_after_useful_life) + if finance_book.expected_value_after_useful_life and ((n == cint(number_of_pending_depreciations) - 1 + and value_after_depreciation != finance_book.expected_value_after_useful_life) + or value_after_depreciation < finance_book.expected_value_after_useful_life): + depreciation_amount += (value_after_depreciation - finance_book.expected_value_after_useful_life) skip_row = True if depreciation_amount > 0: @@ -286,7 +286,7 @@ class Asset(AccountsController): # In pro rata case, for first and last depreciation, month range would be different month_range = months \ if (has_pro_rata and n==0) or (has_pro_rata and n == cint(number_of_pending_depreciations) - 1) \ - else d.frequency_of_depreciation + else finance_book.frequency_of_depreciation for r in range(month_range): if (has_pro_rata and n == 0): @@ -312,17 +312,17 @@ class Asset(AccountsController): self.append("schedules", { "schedule_date": date, "depreciation_amount": amount, - "depreciation_method": d.depreciation_method, - "finance_book": d.finance_book, - "finance_book_id": d.idx + "depreciation_method": finance_book.depreciation_method, + "finance_book": finance_book.finance_book, + "finance_book_id": finance_book.idx }) else: self.append("schedules", { "schedule_date": schedule_date, "depreciation_amount": depreciation_amount, - "depreciation_method": d.depreciation_method, - "finance_book": d.finance_book, - "finance_book_id": d.idx + "depreciation_method": finance_book.depreciation_method, + "finance_book": finance_book.finance_book, + "finance_book_id": finance_book.idx }) # used when depreciation schedule needs to be modified due to increase in asset life From 475d8394e413d530ec57d9c7f713fecd706b2271 Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Mon, 22 Nov 2021 21:23:27 +0530 Subject: [PATCH 03/77] fix: Clear Depreciation Schedule entries that aren't linked with Journal Entries before modifying the schedule --- erpnext/assets/doctype/asset/asset.py | 42 ++++++++++++++++++++++----- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index c00d6836a6..f3825ea682 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -209,7 +209,8 @@ class Asset(AccountsController): number_of_pending_depreciations += 1 skip_row = False - for n in range(start, number_of_pending_depreciations): + + for n in range(start[finance_book.idx-1], number_of_pending_depreciations): # If depreciation is already completed (for double declining balance) if skip_row: continue @@ -325,14 +326,39 @@ class Asset(AccountsController): "finance_book_id": finance_book.idx }) - # used when depreciation schedule needs to be modified due to increase in asset life + # depreciation schedules need to be cleared before modification due to increase in asset life/asset sales + # JE: Journal Entry, FB: Finance Book def clear_depreciation_schedule(self): - start = 0 - for n in range(len(self.schedules)): - if not self.schedules[n].journal_entry: - del self.schedules[n:] - start = n - break + start = [] + num_of_depreciations_completed = 0 + depr_schedule = [] + + for schedule in self.get('schedules'): + + # to ensure that start will only be updated once for each FB + if len(start) == (int(schedule.finance_book_id) - 1): + if schedule.journal_entry: + num_of_depreciations_completed += 1 + depr_schedule.append(schedule) + else: + start.append(num_of_depreciations_completed) + num_of_depreciations_completed = 0 + + # to update start when there are JEs linked with all the schedule rows corresponding to an FB + elif len(start) == (int(schedule.finance_book_id) - 2): + start.append(num_of_depreciations_completed) + num_of_depreciations_completed = 0 + + # to update start when all the schedule rows corresponding to the last FB are linked with JEs + if len(start) == (len(self.finance_books) - 1): + start.append(num_of_depreciations_completed) + + # when the Depreciation Schedule is being created for the first time + if start == []: + start = [0] * len(self.finance_books) + else: + self.schedules = depr_schedule + return start def get_from_date(self, finance_book): From f455de2924f7c7554b9eefc757c354992901136b Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Tue, 23 Nov 2021 23:26:32 +0530 Subject: [PATCH 04/77] fix: Test if multiple Depreciation Schedules are set up for multiple Finance Books --- erpnext/assets/doctype/asset/test_asset.py | 32 ++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index d1d4527ec7..fd4d9ff239 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -890,6 +890,38 @@ class TestDepreciationBasics(AssetSetup): self.assertEqual(len(asset.schedules), 1) + def test_depreciation_schedules_are_set_up_for_multiple_finance_books(self): + asset = create_asset( + item_code = "Macbook Pro", + available_for_use_date = "2019-12-31", + do_not_save = 1 + ) + + asset.calculate_depreciation = 1 + asset.append("finance_books", { + "depreciation_method": "Straight Line", + "frequency_of_depreciation": 12, + "total_number_of_depreciations": 3, + "expected_value_after_useful_life": 10000, + "depreciation_start_date": "2020-12-31" + }) + asset.append("finance_books", { + "depreciation_method": "Straight Line", + "frequency_of_depreciation": 12, + "total_number_of_depreciations": 6, + "expected_value_after_useful_life": 10000, + "depreciation_start_date": "2020-12-31" + }) + asset.save() + + self.assertEqual(len(asset.schedules), 9) + + for schedule in asset.schedules: + if schedule.idx <= 3: + self.assertEqual(schedule.finance_book_id, 1) + else: + self.assertEqual(schedule.finance_book_id, 2) + def test_depreciation_entry_cancellation(self): asset = create_asset( item_code = "Macbook Pro", From 33a0b1db2ced1fe84386dbd6b1e5eacb535e5098 Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Wed, 24 Nov 2021 02:45:17 +0530 Subject: [PATCH 05/77] fix: Retain depreciation schedule rows that are linked with JEs while clearing the schedule --- erpnext/assets/doctype/asset/asset.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index f3825ea682..351556c76c 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -335,6 +335,11 @@ class Asset(AccountsController): for schedule in self.get('schedules'): + # to update start when there are JEs linked with all the schedule rows corresponding to an FB + if len(start) == (int(schedule.finance_book_id) - 2): + start.append(num_of_depreciations_completed) + num_of_depreciations_completed = 0 + # to ensure that start will only be updated once for each FB if len(start) == (int(schedule.finance_book_id) - 1): if schedule.journal_entry: @@ -344,11 +349,6 @@ class Asset(AccountsController): start.append(num_of_depreciations_completed) num_of_depreciations_completed = 0 - # to update start when there are JEs linked with all the schedule rows corresponding to an FB - elif len(start) == (int(schedule.finance_book_id) - 2): - start.append(num_of_depreciations_completed) - num_of_depreciations_completed = 0 - # to update start when all the schedule rows corresponding to the last FB are linked with JEs if len(start) == (len(self.finance_books) - 1): start.append(num_of_depreciations_completed) From 6ec5a190631b563804748fe43532b70d0f230ff9 Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Wed, 24 Nov 2021 03:19:36 +0530 Subject: [PATCH 06/77] fix: Test if clear_depreciation_schedule() works for multiple finance books --- erpnext/assets/doctype/asset/test_asset.py | 44 ++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index fd4d9ff239..c1a978572e 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -890,6 +890,50 @@ class TestDepreciationBasics(AssetSetup): self.assertEqual(len(asset.schedules), 1) + def test_clear_depreciation_schedule_for_multiple_finance_books(self): + asset = create_asset( + item_code = "Macbook Pro", + available_for_use_date = "2019-12-31", + do_not_save = 1 + ) + + asset.calculate_depreciation = 1 + asset.append("finance_books", { + "depreciation_method": "Straight Line", + "frequency_of_depreciation": 12, + "total_number_of_depreciations": 3, + "expected_value_after_useful_life": 10000, + "depreciation_start_date": "2020-12-31" + }) + asset.append("finance_books", { + "depreciation_method": "Straight Line", + "frequency_of_depreciation": 12, + "total_number_of_depreciations": 6, + "expected_value_after_useful_life": 10000, + "depreciation_start_date": "2020-12-31" + }) + asset.append("finance_books", { + "depreciation_method": "Straight Line", + "frequency_of_depreciation": 12, + "total_number_of_depreciations": 3, + "expected_value_after_useful_life": 10000, + "depreciation_start_date": "2023-12-31" + }) + asset.submit() + + post_depreciation_entries(date="2023-01-01") + asset.load_from_db() + + asset.clear_depreciation_schedule() + + self.assertEqual(len(asset.schedules), 6) + + for schedule in asset.schedules: + if schedule.idx <= 3: + self.assertEqual(schedule.finance_book_id, "1") + else: + self.assertEqual(schedule.finance_book_id, "2") + def test_depreciation_schedules_are_set_up_for_multiple_finance_books(self): asset = create_asset( item_code = "Macbook Pro", From 4311936b7dd2bf7e3f5e11fe5ce6eeff03a7d49b Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Wed, 24 Nov 2021 05:21:31 +0530 Subject: [PATCH 07/77] fix: Edit dates and frequency of depreciation --- erpnext/assets/doctype/asset/test_asset.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/erpnext/assets/doctype/asset/test_asset.py b/erpnext/assets/doctype/asset/test_asset.py index c1a978572e..a297a5a501 100644 --- a/erpnext/assets/doctype/asset/test_asset.py +++ b/erpnext/assets/doctype/asset/test_asset.py @@ -900,28 +900,28 @@ class TestDepreciationBasics(AssetSetup): asset.calculate_depreciation = 1 asset.append("finance_books", { "depreciation_method": "Straight Line", - "frequency_of_depreciation": 12, + "frequency_of_depreciation": 1, "total_number_of_depreciations": 3, "expected_value_after_useful_life": 10000, - "depreciation_start_date": "2020-12-31" + "depreciation_start_date": "2020-01-31" }) asset.append("finance_books", { "depreciation_method": "Straight Line", - "frequency_of_depreciation": 12, + "frequency_of_depreciation": 1, "total_number_of_depreciations": 6, "expected_value_after_useful_life": 10000, - "depreciation_start_date": "2020-12-31" + "depreciation_start_date": "2020-01-31" }) asset.append("finance_books", { "depreciation_method": "Straight Line", "frequency_of_depreciation": 12, "total_number_of_depreciations": 3, "expected_value_after_useful_life": 10000, - "depreciation_start_date": "2023-12-31" + "depreciation_start_date": "2020-12-31" }) asset.submit() - post_depreciation_entries(date="2023-01-01") + post_depreciation_entries(date="2020-04-01") asset.load_from_db() asset.clear_depreciation_schedule() From 9047e2b9dd8b6d8b1819404d98480d86b598d562 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Wed, 24 Nov 2021 13:52:20 +0530 Subject: [PATCH 08/77] refactor: do not set default priority in isssues --- erpnext/support/doctype/issue/issue.json | 4 ++-- erpnext/support/doctype/issue/issue_list.js | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/erpnext/support/doctype/issue/issue.json b/erpnext/support/doctype/issue/issue.json index 14712f89fe..75b6d0f4f9 100644 --- a/erpnext/support/doctype/issue/issue.json +++ b/erpnext/support/doctype/issue/issue.json @@ -123,7 +123,6 @@ "search_index": 1 }, { - "default": "Medium", "fieldname": "priority", "fieldtype": "Link", "in_list_view": 1, @@ -410,10 +409,11 @@ "icon": "fa fa-ticket", "idx": 7, "links": [], - "modified": "2021-06-10 03:22:27.098898", + "modified": "2021-11-24 13:13:10.276630", "modified_by": "Administrator", "module": "Support", "name": "Issue", + "naming_rule": "By \"Naming Series\" field", "owner": "Administrator", "permissions": [ { diff --git a/erpnext/support/doctype/issue/issue_list.js b/erpnext/support/doctype/issue/issue_list.js index e04498e29e..5bfecb019c 100644 --- a/erpnext/support/doctype/issue/issue_list.js +++ b/erpnext/support/doctype/issue/issue_list.js @@ -18,7 +18,6 @@ frappe.listview_settings['Issue'] = { }, get_indicator: function(doc) { if (doc.status === 'Open') { - if (!doc.priority) doc.priority = 'Medium'; const color = { 'Low': 'yellow', 'Medium': 'orange', From 1f060c0b0a2d1925ebfda8b269517462d428ef35 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Wed, 24 Nov 2021 13:53:39 +0530 Subject: [PATCH 09/77] feat: find SLA based on customer group's ancestors --- .../service_level_agreement.py | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py index 5f8f83d89b..9dfe339c51 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py @@ -22,6 +22,7 @@ from frappe.utils import ( time_diff_in_seconds, to_timedelta, ) +from frappe.utils.nestedset import get_ancestors_of from frappe.utils.safe_exec import get_safe_globals from erpnext.support.doctype.issue.issue import get_holidays @@ -248,7 +249,7 @@ def get_active_service_level_agreement_for(doc): customer = doc.get('customer') or_filters.append( - ["Service Level Agreement", "entity", "in", [customer, get_customer_group(customer), get_customer_territory(customer)]] + ["Service Level Agreement", "entity", "in", [customer] + get_customer_group(customer) + get_customer_territory(customer)] ) default_sla_filter = filters + [["Service Level Agreement", "default_service_level_agreement", "=", 1]] @@ -275,11 +276,23 @@ def get_context(doc): return {"doc": doc.as_dict(), "nowdate": nowdate, "frappe": frappe._dict(utils=get_safe_globals().get("frappe").get("utils"))} def get_customer_group(customer): - return frappe.db.get_value("Customer", customer, "customer_group") if customer else None + customer_groups = [] + customer_group = frappe.db.get_value("Customer", customer, "customer_group") if customer else None + if customer_group: + ancestors = get_ancestors_of("Customer Group", customer_group) + customer_groups = [customer_group] + ancestors + + return customer_groups def get_customer_territory(customer): - return frappe.db.get_value("Customer", customer, "territory") if customer else None + customer_territories = [] + customer_territory = frappe.db.get_value("Customer", customer, "territory") if customer else None + if customer_territory: + ancestors = get_ancestors_of("Territory", customer_territory) + customer_territories = [customer_territory] + ancestors + + return customer_territories @frappe.whitelist() @@ -299,7 +312,7 @@ def get_service_level_agreement_filters(doctype, name, customer=None): if customer: # Include SLA with No Entity and Entity Type or_filters.append( - ["Service Level Agreement", "entity", "in", [customer, get_customer_group(customer), get_customer_territory(customer), ""]] + ["Service Level Agreement", "entity", "in", [""] + [customer] + get_customer_group(customer) + get_customer_territory(customer)] ) return { @@ -343,6 +356,8 @@ def apply(doc, method=None): service_level_agreement = get_active_service_level_agreement_for(doc) + print(service_level_agreement) + if not service_level_agreement: return From c46c8dd6c57a1d12df977b02c14affaf479e1bb3 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Wed, 24 Nov 2021 19:23:31 +0530 Subject: [PATCH 10/77] feat: do not change variance if response or resolution is set --- .../service_level_agreement.py | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py index 9dfe339c51..a25608b275 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py @@ -515,17 +515,21 @@ def set_service_level_agreement_variance(doctype, doc=None): if not current_doc.first_responded_on: # first_responded_on set when first reply is sent to customer variance = round(time_diff_in_seconds(current_doc.response_by, current_time), 2) - frappe.db.set_value(current_doc.doctype, current_doc.name, "response_by_variance", variance, update_modified=False) + else: + variance = round(time_diff_in_seconds(current_doc.response_by, current_doc.first_responded_on), 2) - if variance < 0: - frappe.db.set_value(current_doc.doctype, current_doc.name, "agreement_status", "Failed", update_modified=False) + frappe.db.set_value(current_doc.doctype, current_doc.name, "response_by_variance", variance, update_modified=False) + if variance < 0: + frappe.db.set_value(current_doc.doctype, current_doc.name, "agreement_status", "Failed", update_modified=False) if apply_sla_for_resolution and not current_doc.get("resolution_date"): # resolution_date set when issue has been closed variance = round(time_diff_in_seconds(current_doc.resolution_by, current_time), 2) - frappe.db.set_value(current_doc.doctype, current_doc.name, "resolution_by_variance", variance, update_modified=False) + elif apply_sla_for_resolution and current_doc.get("resolution_date"): + variance = round(time_diff_in_seconds(current_doc.resolution_by, current_doc.get("resolution_date")), 2) - if variance < 0: - frappe.db.set_value(current_doc.doctype, current_doc.name, "agreement_status", "Failed", update_modified=False) + frappe.db.set_value(current_doc.doctype, current_doc.name, "resolution_by_variance", variance, update_modified=False) + if variance < 0: + frappe.db.set_value(current_doc.doctype, current_doc.name, "agreement_status", "Failed", update_modified=False) def set_user_resolution_time(doc, meta): @@ -808,10 +812,18 @@ def update_agreement_status_on_custom_status(doc): # first_responded_on set when first reply is sent to customer doc.response_by_variance = round(time_diff_in_seconds(doc.response_by, now_time), 2) + if meta.has_field("first_responded_on") and doc.first_responded_on: + # first_responded_on set when first reply is sent to customer + doc.response_by_variance = round(time_diff_in_seconds(doc.response_by, doc.first_responded_on), 2) + if meta.has_field("resolution_date") and not doc.resolution_date: # resolution_date set when issue has been closed doc.resolution_by_variance = round(time_diff_in_seconds(doc.resolution_by, now_time), 2) + if meta.has_field("resolution_date") and doc.resolution_date: + # resolution_date set when issue has been closed + doc.resolution_by_variance = round(time_diff_in_seconds(doc.resolution_by, doc.resolution_date), 2) + if meta.has_field("agreement_status"): doc.agreement_status = "Fulfilled" if doc.response_by_variance > 0 and doc.resolution_by_variance > 0 else "Failed" @@ -857,6 +869,8 @@ def set_response_by_and_variance(doc, meta, start_date_time, priority): if meta.has_field("response_by_variance") and not doc.get('first_responded_on'): now_time = frappe.flags.current_time or now_datetime(doc.get("owner")) doc.response_by_variance = round(time_diff_in_seconds(doc.response_by, now_time), 2) + elif meta.has_field("response_by_variance") and doc.get('first_responded_on'): + doc.response_by_variance = round(time_diff_in_seconds(doc.response_by, doc.get('first_responded_on')), 2) def set_resolution_by_and_variance(doc, meta, start_date_time, priority): if meta.has_field("resolution_by"): From 210f593a492ba4332c526fac6579df9adc3afc1c Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Fri, 26 Nov 2021 15:49:17 +0530 Subject: [PATCH 11/77] refactor: SLA form fields --- .../service_level_agreement.js | 31 ++++++++++ .../service_level_agreement.json | 61 ++++++++----------- 2 files changed, 56 insertions(+), 36 deletions(-) diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.js b/erpnext/support/doctype/service_level_agreement/service_level_agreement.js index ae2080c3b5..7e260dbbf5 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.js +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.js @@ -22,10 +22,41 @@ frappe.ui.form.on('Service Level Agreement', { refresh: function(frm) { frm.trigger('fetch_status_fields'); frm.trigger('toggle_resolution_fields'); + frm.trigger('default_service_level_agreement'); + frm.trigger('entity'); + }, + + default_service_level_agreement: function(frm) { + const field = frm.get_field('default_service_level_agreement'); + if (frm.doc.default_service_level_agreement) { + field.set_description(__('SLA will be applied on every {0}', [frm.doc.document_type])); + } else { + field.set_description(__('Enable to apply SLA on every {0}', [frm.doc.document_type])); + } }, document_type: function(frm) { frm.trigger('fetch_status_fields'); + frm.trigger('default_service_level_agreement'); + }, + + entity_type: function(frm) { + frm.set_value('entity', undefined); + }, + + entity: function(frm) { + const field = frm.get_field('entity'); + if (frm.doc.entity) { + const and_descendants = frm.doc.entity_type != 'Customer' ? __(' or its descendants') : ''; + field.set_description( + __('SLA will be applied if {1} is set as {2}{3}', [ + frm.doc.document_type, frm.doc.entity_type, + frm.doc.entity, and_descendants + ]) + ); + } else { + field.set_description(''); + } }, fetch_status_fields: function(frm) { diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.json b/erpnext/support/doctype/service_level_agreement/service_level_agreement.json index 5f470aad67..1698e2380f 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.json +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.json @@ -6,22 +6,17 @@ "editable_grid": 1, "engine": "InnoDB", "field_order": [ - "enabled", - "section_break_2", "document_type", - "default_service_level_agreement", "default_priority", "column_break_2", "service_level", - "holiday_list", - "entity_section", - "entity_type", - "column_break_10", - "entity", + "enabled", "filters_section", - "condition", + "default_service_level_agreement", + "entity_type", + "entity", "column_break_15", - "condition_description", + "condition", "agreement_details_section", "start_date", "column_break_7", @@ -31,8 +26,10 @@ "priorities", "status_details", "sla_fulfilled_on", + "column_break_22", "pause_sla_on", "support_and_resolution_section_break", + "holiday_list", "support_and_resolution" ], "fields": [ @@ -42,7 +39,8 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Service Level Name", - "reqd": 1 + "reqd": 1, + "set_only_once": 1 }, { "fieldname": "holiday_list", @@ -56,10 +54,10 @@ "fieldtype": "Column Break" }, { - "depends_on": "eval: !doc.default_service_level_agreement", + "depends_on": "eval: doc.document_type", "fieldname": "agreement_details_section", "fieldtype": "Section Break", - "label": "Agreement Details" + "label": "Valid From" }, { "fieldname": "start_date", @@ -72,7 +70,6 @@ "fieldtype": "Column Break" }, { - "depends_on": "eval: !doc.default_service_level_agreement", "fieldname": "end_date", "fieldtype": "Date", "label": "End Date" @@ -80,7 +77,7 @@ { "fieldname": "response_and_resolution_time_section", "fieldtype": "Section Break", - "label": "Response and Resolution Time" + "label": "Response and Resolution" }, { "fieldname": "support_and_resolution_section_break", @@ -90,6 +87,7 @@ { "fieldname": "support_and_resolution", "fieldtype": "Table", + "label": "Working Hours", "options": "Service Day", "reqd": 1 }, @@ -101,10 +99,7 @@ "reqd": 1 }, { - "fieldname": "column_break_10", - "fieldtype": "Column Break" - }, - { + "depends_on": "eval: !doc.default_service_level_agreement", "fieldname": "entity", "fieldtype": "Dynamic Link", "in_list_view": 1, @@ -114,22 +109,12 @@ }, { "depends_on": "eval: !doc.default_service_level_agreement", - "fieldname": "entity_section", - "fieldtype": "Section Break", - "label": "Entity" - }, - { "fieldname": "entity_type", "fieldtype": "Select", "in_standard_filter": 1, "label": "Entity Type", "options": "\nCustomer\nCustomer Group\nTerritory" }, - { - "fieldname": "section_break_2", - "fieldtype": "Section Break", - "hide_border": 1 - }, { "default": "0", "fieldname": "default_service_level_agreement", @@ -152,7 +137,7 @@ { "fieldname": "document_type", "fieldtype": "Link", - "label": "Document Type", + "label": "Apply On", "options": "DocType", "reqd": 1, "set_only_once": 1 @@ -164,6 +149,7 @@ "label": "Enabled" }, { + "depends_on": "document_type", "fieldname": "status_details", "fieldtype": "Section Break", "label": "Status Details" @@ -182,28 +168,31 @@ "label": "Apply SLA for Resolution Time" }, { + "depends_on": "document_type", "fieldname": "filters_section", "fieldtype": "Section Break", - "label": "Assignment Condition" + "label": "Assignment Conditions" }, { "fieldname": "column_break_15", "fieldtype": "Column Break" }, { + "depends_on": "eval: !doc.default_service_level_agreement", + "description": "Simple Python Expression, Example: doc.status == 'Open' and doc.issue_type == 'Bug'", "fieldname": "condition", "fieldtype": "Code", "label": "Condition", - "options": "Python" + "max_height": "7rem", + "options": "PythonExpression" }, { - "fieldname": "condition_description", - "fieldtype": "HTML", - "options": "

Condition Examples:

\n
doc.status==\"Open\"
doc.due_date==nowdate()
doc.total > 40000\n
" + "fieldname": "column_break_22", + "fieldtype": "Column Break" } ], "links": [], - "modified": "2021-10-02 11:32:55.556024", + "modified": "2021-11-26 15:45:33.289911", "modified_by": "Administrator", "module": "Support", "name": "Service Level Agreement", From a8c75b68628d29cb354ae3cb62424d3a01923586 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Fri, 26 Nov 2021 20:16:21 +0530 Subject: [PATCH 12/77] refactor: application of SLA and its metrics --- erpnext/hooks.py | 3 +- .../service_level_agreement.py | 339 +++++++----------- 2 files changed, 137 insertions(+), 205 deletions(-) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 2a277ee035..186dbb5847 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -341,8 +341,7 @@ scheduler_events = { "erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.automatic_synchronization", "erpnext.projects.doctype.project.project.hourly_reminder", "erpnext.projects.doctype.project.project.collect_project_status", - "erpnext.hr.doctype.shift_type.shift_type.process_auto_attendance_for_all_shifts", - "erpnext.support.doctype.service_level_agreement.service_level_agreement.set_service_level_agreement_variance" + "erpnext.hr.doctype.shift_type.shift_type.process_auto_attendance_for_all_shifts" ], "hourly_long": [ "erpnext.stock.doctype.repost_item_valuation.repost_item_valuation.repost_entries" diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py index a25608b275..864640204b 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py @@ -10,7 +10,6 @@ from frappe.core.utils import get_parent_doc from frappe.model.document import Document from frappe.utils import ( add_to_date, - cint, get_datetime, get_datetime_str, get_link_to_form, @@ -350,86 +349,113 @@ def set_documents_with_active_service_level_agreement(): def apply(doc, method=None): # Applies SLA to document on validate - if frappe.flags.in_patch or frappe.flags.in_migrate or frappe.flags.in_install or frappe.flags.in_setup_wizard or \ - doc.doctype not in get_documents_with_active_service_level_agreement(): + if ( + frappe.flags.in_patch + or frappe.flags.in_migrate + or frappe.flags.in_install + or frappe.flags.in_setup_wizard + or doc.doctype not in get_documents_with_active_service_level_agreement() + ): return service_level_agreement = get_active_service_level_agreement_for(doc) - print(service_level_agreement) - if not service_level_agreement: return - set_sla_properties(doc, service_level_agreement) + process_sla(doc, service_level_agreement) -def set_sla_properties(doc, service_level_agreement): - if frappe.db.exists(doc.doctype, doc.name): - from_db = frappe.get_doc(doc.doctype, doc.name) - else: - from_db = frappe._dict({}) - - meta = frappe.get_meta(doc.doctype) - - if meta.has_field("customer") and service_level_agreement.customer and doc.get("customer") and \ - not service_level_agreement.customer == doc.get("customer"): - frappe.throw(_("Service Level Agreement {0} is specific to Customer {1}").format(service_level_agreement.name, - service_level_agreement.customer)) - - doc.service_level_agreement = service_level_agreement.name - doc.priority = doc.get("priority") or service_level_agreement.default_priority - priority = get_priority(doc) +def process_sla(doc, service_level_agreement): if not doc.creation: doc.creation = now_datetime(doc.get("owner")) - - if meta.has_field("service_level_agreement_creation"): + if doc.meta.has_field("service_level_agreement_creation"): doc.service_level_agreement_creation = now_datetime(doc.get("owner")) + doc.service_level_agreement = service_level_agreement.name + doc.priority = doc.get("priority") or service_level_agreement.default_priority + + prev_status = frappe.db.get_value(doc.doctype, doc.name, 'status') + handle_status_change(doc, prev_status, service_level_agreement.apply_sla_for_resolution) + update_response_and_resolution_metrics(doc, service_level_agreement.apply_sla_for_resolution) + update_agreement_status(doc, service_level_agreement.apply_sla_for_resolution) + + +def update_response_and_resolution_metrics(doc, apply_sla_for_resolution): + priority = get_response_and_resolution_duration(doc) start_date_time = get_datetime(doc.get("service_level_agreement_creation") or doc.creation) - - set_response_by_and_variance(doc, meta, start_date_time, priority) - if service_level_agreement.apply_sla_for_resolution: - set_resolution_by_and_variance(doc, meta, start_date_time, priority) - - update_status(doc, from_db, meta) + set_response_by_and_variance(doc, start_date_time, priority) + if apply_sla_for_resolution: + set_resolution_by_and_variance(doc, start_date_time, priority) -def update_status(doc, from_db, meta): - if meta.has_field("status"): - if meta.has_field("first_responded_on") and doc.status != "Open" and \ - from_db.status == "Open" and not doc.first_responded_on: +def get_fulfillment_statuses(service_level_agreement): + return [entry.status for entry in frappe.db.get_all("SLA Fulfilled On Status", filters={ + "parent": service_level_agreement + }, fields=["status"])] + + +def get_hold_statuses(service_level_agreement): + return [entry.status for entry in frappe.db.get_all("Pause SLA On Status", filters={ + "parent": service_level_agreement + }, fields=["status"])] + + +def handle_status_change(doc, prev_status, apply_sla_for_resolution): + + if doc.status != "Open" and prev_status == "Open": + # status changed from Open to something else + if doc.meta.has_field("first_responded_on") and not doc.first_responded_on: + # status changed to something other than Open doc.first_responded_on = frappe.flags.current_time or now_datetime(doc.get("owner")) - if meta.has_field("service_level_agreement") and doc.service_level_agreement: - # mark sla status as fulfilled based on the configuration - fulfillment_statuses = [entry.status for entry in frappe.db.get_all("SLA Fulfilled On Status", filters={ - "parent": doc.service_level_agreement - }, fields=["status"])] + if doc.status == "Open" and prev_status != "Open": + # status changed from something else to Open + reset_resolution_metrics(doc) - if doc.status in fulfillment_statuses and from_db.status not in fulfillment_statuses: - apply_sla_for_resolution = frappe.db.get_value("Service Level Agreement", doc.service_level_agreement, - "apply_sla_for_resolution") + handle_fulfillment_status(doc, prev_status, apply_sla_for_resolution) + handle_hold_status(doc, prev_status) - if apply_sla_for_resolution and meta.has_field("resolution_date"): - doc.resolution_date = frappe.flags.current_time or now_datetime(doc.get("owner")) - if meta.has_field("agreement_status") and from_db.agreement_status == "Ongoing": - set_service_level_agreement_variance(doc.doctype, doc.name) - update_agreement_status(doc, meta) +def handle_fulfillment_status(doc, prev_status, apply_sla_for_resolution): + fulfillment_statuses = get_fulfillment_statuses(doc.service_level_agreement) + if ( + doc.status in fulfillment_statuses + and prev_status not in fulfillment_statuses + and apply_sla_for_resolution + ): + # status changed to any fulfillment_statuses + if doc.meta.has_field("resolution_date"): + doc.resolution_date = frappe.flags.current_time or now_datetime(doc.get("owner")) + if doc.meta.has_field("resolution_time"): + doc.resolution_time = time_diff_in_seconds(doc.resolution_date, doc.creation) + set_user_resolution_time(doc) - if apply_sla_for_resolution: - set_resolution_time(doc, meta) - set_user_resolution_time(doc, meta) - if doc.status == "Open" and from_db.status != "Open": - # if no date, it should be set as None and not a blank string "", as per mysql strict config - # enable SLA and variance on Reopen - reset_metrics(doc, meta) - set_service_level_agreement_variance(doc.doctype, doc.name) +def handle_hold_status(doc, prev_status): + hold_statuses = get_hold_statuses(doc.service_level_agreement) + if doc.status in hold_statuses: + # reset if status is a hold status, regardless of previous status + reset_expected_response_and_resolution(doc) + if prev_status not in hold_statuses: + # set on_hold_since status changed from any non-hold status + # for eg. doc.status changed from Open to Replied + if doc.meta.has_field("on_hold_since"): + doc.on_hold_since = frappe.flags.current_time or now_datetime(doc.get("owner")) - handle_hold_time(doc, meta, from_db.status) + if doc.status not in hold_statuses and prev_status in hold_statuses: + # status changed to any non-hold status + # for eg. doc.status changed from Replied to Closed + if doc.meta.has_field("on_hold_since") and doc.on_hold_since: + cumulate_hold_time(doc) + doc.on_hold_since = None + + +def cumulate_hold_time(doc): + now_time = frappe.flags.current_time or now_datetime(doc.get("owner")) + on_hold_duration = time_diff_in_seconds(now_time, doc.on_hold_since) + doc.total_hold_time = (doc.total_hold_time or 0) + on_hold_duration def get_expected_time_for(parameter, service_level, start_date_time): @@ -500,41 +526,9 @@ def get_support_days(service_level): return support_days -def set_service_level_agreement_variance(doctype, doc=None): - - filters = {"status": "Open", "agreement_status": "Ongoing"} - - if doc: - filters = {"name": doc} - - for entry in frappe.get_all(doctype, filters=filters): - current_doc = frappe.get_doc(doctype, entry.name) - current_time = frappe.flags.current_time or now_datetime(current_doc.get("owner")) - apply_sla_for_resolution = frappe.db.get_value("Service Level Agreement", current_doc.service_level_agreement, - "apply_sla_for_resolution") - - if not current_doc.first_responded_on: # first_responded_on set when first reply is sent to customer - variance = round(time_diff_in_seconds(current_doc.response_by, current_time), 2) - else: - variance = round(time_diff_in_seconds(current_doc.response_by, current_doc.first_responded_on), 2) - - frappe.db.set_value(current_doc.doctype, current_doc.name, "response_by_variance", variance, update_modified=False) - if variance < 0: - frappe.db.set_value(current_doc.doctype, current_doc.name, "agreement_status", "Failed", update_modified=False) - - if apply_sla_for_resolution and not current_doc.get("resolution_date"): # resolution_date set when issue has been closed - variance = round(time_diff_in_seconds(current_doc.resolution_by, current_time), 2) - elif apply_sla_for_resolution and current_doc.get("resolution_date"): - variance = round(time_diff_in_seconds(current_doc.resolution_by, current_doc.get("resolution_date")), 2) - - frappe.db.set_value(current_doc.doctype, current_doc.name, "resolution_by_variance", variance, update_modified=False) - if variance < 0: - frappe.db.set_value(current_doc.doctype, current_doc.name, "agreement_status", "Failed", update_modified=False) - - -def set_user_resolution_time(doc, meta): +def set_user_resolution_time(doc): # total time taken by a user to close the issue apart from wait_time - if not meta.has_field("user_resolution_time"): + if not doc.meta.has_field("user_resolution_time"): return communications = frappe.get_all("Communication", filters={ @@ -567,7 +561,7 @@ def change_service_level_agreement_and_priority(self): frappe.msgprint(_("Service Level Agreement has been changed to {0}.").format(self.service_level_agreement)) -def get_priority(doc): +def get_response_and_resolution_duration(doc): service_level_agreement = frappe.get_doc("Service Level Agreement", doc.service_level_agreement) priority = service_level_agreement.get_service_level_agreement_priority(doc.priority) priority.update({ @@ -596,115 +590,81 @@ def reset_service_level_agreement(doc, reason, user): doc.save() -def reset_metrics(doc, meta): - if meta.has_field("resolution_date"): +def reset_resolution_metrics(doc): + if doc.meta.has_field("resolution_date"): doc.resolution_date = None - if not meta.has_field("resolution_time"): + if doc.meta.has_field("resolution_time"): doc.resolution_time = None - if not meta.has_field("user_resolution_time"): + if doc.meta.has_field("user_resolution_time"): doc.user_resolution_time = None - if meta.has_field("agreement_status"): + if doc.meta.has_field("agreement_status"): doc.agreement_status = "Ongoing" -def set_resolution_time(doc, meta): - # total time taken from issue creation to closing - if not meta.has_field("resolution_time"): - return - - doc.resolution_time = time_diff_in_seconds(doc.resolution_date, doc.creation) - - # called via hooks on communication update def update_hold_time(doc, status): + if doc.communication_type == "Comment" or doc.sent_or_received != "Received": + return + parent = get_parent_doc(doc) if not parent: return - if doc.communication_type == "Comment": + if not parent.meta.has_field('service_level_agreement'): return - status_field = parent.meta.get_field("status") - if status_field: - options = (status_field.options or "").splitlines() + apply_sla_for_resolution = frappe.db.get_value('Service Level Agreement', parent.service_level_agreement, 'apply_sla_for_resolution') - # if status has a "Replied" option, then handle hold time - if ("Replied" in options) and doc.sent_or_received == "Received": - meta = frappe.get_meta(parent.doctype) - handle_hold_time(parent, meta, 'Replied') + handle_status_change(parent, 'Replied', apply_sla_for_resolution) + update_response_and_resolution_metrics(parent, apply_sla_for_resolution) + update_agreement_status(parent, apply_sla_for_resolution) + + parent.save() -def handle_hold_time(doc, meta, status): - if meta.has_field("service_level_agreement") and doc.service_level_agreement: - # set response and resolution variance as None as the issue is on Hold for status as Replied - hold_statuses = [entry.status for entry in frappe.db.get_all("Pause SLA On Status", filters={ - "parent": doc.service_level_agreement - }, fields=["status"])] +def reset_expected_response_and_resolution(doc): + update_values = {} - if not hold_statuses: - return - - if meta.has_field("status") and doc.status in hold_statuses and status not in hold_statuses: - apply_hold_status(doc, meta) - - # calculate hold time when status is changed from any hold status to any non-hold status - if meta.has_field("status") and doc.status not in hold_statuses and status in hold_statuses: - reset_hold_status_and_update_hold_time(doc, meta) - - -def apply_hold_status(doc, meta): - update_values = {'on_hold_since': frappe.flags.current_time or now_datetime(doc.get("owner"))} - - if meta.has_field("first_responded_on") and not doc.first_responded_on: + if doc.meta.has_field("first_responded_on") and not doc.first_responded_on: update_values['response_by'] = None update_values['response_by_variance'] = 0 - update_values['resolution_by'] = None - update_values['resolution_by_variance'] = 0 + if doc.meta.has_field("resolution_by") and not doc.resolution_date: + update_values['resolution_by'] = None + update_values['resolution_by_variance'] = 0 doc.db_set(update_values) -def reset_hold_status_and_update_hold_time(doc, meta): - hold_time = doc.total_hold_time if meta.has_field("total_hold_time") and doc.total_hold_time else 0 - now_time = frappe.flags.current_time or now_datetime(doc.get("owner")) - last_hold_time = 0 - update_values = {} +def set_response_by_and_variance(doc, start_date_time, priority): + if doc.meta.has_field("response_by"): + doc.response_by = get_expected_time_for(parameter="response", service_level=priority, start_date_time=start_date_time) + if doc.meta.has_field("total_hold_time") and doc.total_hold_time: + doc.response_by = add_to_date(doc.response_by, seconds=round(doc.total_hold_time)) - if meta.has_field("on_hold_since") and doc.on_hold_since: - # last_hold_time will be added to the sla variables - last_hold_time = time_diff_in_seconds(now_time, doc.on_hold_since) - update_values['total_hold_time'] = hold_time + last_hold_time + if doc.meta.has_field("response_by_variance") and not doc.get('first_responded_on'): + now_time = frappe.flags.current_time or now_datetime(doc.get("owner")) + doc.response_by_variance = round(time_diff_in_seconds(doc.response_by, now_time), 2) - # re-calculate SLA variables after issue changes from any hold status to any non-hold status - start_date_time = get_datetime(doc.get("service_level_agreement_creation") or doc.creation) - priority = get_priority(doc) - now_time = frappe.flags.current_time or now_datetime(doc.get("owner")) + if doc.meta.has_field("response_by_variance") and doc.get('first_responded_on'): + doc.response_by_variance = round(time_diff_in_seconds(doc.response_by, doc.get('first_responded_on')), 2) - # add hold time to response by variance - if meta.has_field("first_responded_on") and not doc.first_responded_on: - response_by = get_expected_time_for(parameter="response", service_level=priority, start_date_time=start_date_time) - response_by = add_to_date(response_by, seconds=round(last_hold_time)) - response_by_variance = round(time_diff_in_seconds(response_by, now_time)) - update_values['response_by'] = response_by - update_values['response_by_variance'] = response_by_variance + last_hold_time +def set_resolution_by_and_variance(doc, start_date_time, priority): + if doc.meta.has_field("resolution_by"): + doc.resolution_by = get_expected_time_for(parameter="resolution", service_level=priority, start_date_time=start_date_time) + if doc.meta.has_field("total_hold_time") and doc.total_hold_time: + doc.resolution_by = add_to_date(doc.resolution_by, seconds=round(doc.total_hold_time)) - # add hold time to resolution by variance - if frappe.db.get_value("Service Level Agreement", doc.service_level_agreement, "apply_sla_for_resolution"): - resolution_by = get_expected_time_for(parameter="resolution", service_level=priority, start_date_time=start_date_time) - resolution_by = add_to_date(resolution_by, seconds=round(last_hold_time)) - resolution_by_variance = round(time_diff_in_seconds(resolution_by, now_time)) + if doc.meta.has_field("resolution_by_variance") and not doc.get("resolution_date"): + now_time = frappe.flags.current_time or now_datetime(doc.get("owner")) + doc.resolution_by_variance = round(time_diff_in_seconds(doc.resolution_by, now_time), 2) - update_values['resolution_by'] = resolution_by - update_values['resolution_by_variance'] = resolution_by_variance + last_hold_time - - update_values['on_hold_since'] = None - - doc.db_set(update_values) + if doc.meta.has_field("resolution_by_variance") and doc.get('resolution_date'): + doc.resolution_by_variance = round(time_diff_in_seconds(doc.resolution_by, doc.get('resolution_date')), 2) def get_service_level_agreement_fields(): @@ -808,45 +768,37 @@ def update_agreement_status_on_custom_status(doc): meta = frappe.get_meta(doc.doctype) now_time = frappe.flags.current_time or now_datetime(doc.get("owner")) - if meta.has_field("first_responded_on") and not doc.first_responded_on: + if doc.meta.has_field("first_responded_on") and not doc.first_responded_on: # first_responded_on set when first reply is sent to customer doc.response_by_variance = round(time_diff_in_seconds(doc.response_by, now_time), 2) - if meta.has_field("first_responded_on") and doc.first_responded_on: + if doc.meta.has_field("first_responded_on") and doc.first_responded_on: # first_responded_on set when first reply is sent to customer doc.response_by_variance = round(time_diff_in_seconds(doc.response_by, doc.first_responded_on), 2) - if meta.has_field("resolution_date") and not doc.resolution_date: + if doc.meta.has_field("resolution_date") and not doc.resolution_date: # resolution_date set when issue has been closed doc.resolution_by_variance = round(time_diff_in_seconds(doc.resolution_by, now_time), 2) - if meta.has_field("resolution_date") and doc.resolution_date: + if doc.meta.has_field("resolution_date") and doc.resolution_date: # resolution_date set when issue has been closed doc.resolution_by_variance = round(time_diff_in_seconds(doc.resolution_by, doc.resolution_date), 2) - if meta.has_field("agreement_status"): + if doc.meta.has_field("agreement_status"): doc.agreement_status = "Fulfilled" if doc.response_by_variance > 0 and doc.resolution_by_variance > 0 else "Failed" -def update_agreement_status(doc, meta): - if meta.has_field("service_level_agreement") and meta.has_field("agreement_status") and \ - doc.service_level_agreement and doc.agreement_status == "Ongoing": - - apply_sla_for_resolution = frappe.db.get_value("Service Level Agreement", doc.service_level_agreement, - "apply_sla_for_resolution") - +def update_agreement_status(doc, apply_sla_for_resolution): + if (doc.meta.has_field("agreement_status")): # if SLA is applied for resolution check for response and resolution, else only response if apply_sla_for_resolution: - if meta.has_field("response_by_variance") and meta.has_field("resolution_by_variance"): - if cint(frappe.db.get_value(doc.doctype, doc.name, "response_by_variance")) < 0 or \ - cint(frappe.db.get_value(doc.doctype, doc.name, "resolution_by_variance")) < 0: - + if doc.meta.has_field("response_by_variance") and doc.meta.has_field("resolution_by_variance"): + if doc.response_by_variance < 0 or doc.resolution_by_variance < 0: doc.agreement_status = "Failed" else: doc.agreement_status = "Fulfilled" else: - if meta.has_field("response_by_variance") and \ - cint(frappe.db.get_value(doc.doctype, doc.name, "response_by_variance")) < 0: + if doc.meta.has_field("response_by_variance") and doc.response_by_variance < 0: doc.agreement_status = "Failed" else: doc.agreement_status = "Fulfilled" @@ -862,25 +814,6 @@ def get_time_in_timedelta(time): return datetime.timedelta(hours=time.hour, minutes=time.minute, seconds=time.second) -def set_response_by_and_variance(doc, meta, start_date_time, priority): - if meta.has_field("response_by"): - doc.response_by = get_expected_time_for(parameter="response", service_level=priority, start_date_time=start_date_time) - - if meta.has_field("response_by_variance") and not doc.get('first_responded_on'): - now_time = frappe.flags.current_time or now_datetime(doc.get("owner")) - doc.response_by_variance = round(time_diff_in_seconds(doc.response_by, now_time), 2) - elif meta.has_field("response_by_variance") and doc.get('first_responded_on'): - doc.response_by_variance = round(time_diff_in_seconds(doc.response_by, doc.get('first_responded_on')), 2) - -def set_resolution_by_and_variance(doc, meta, start_date_time, priority): - if meta.has_field("resolution_by"): - doc.resolution_by = get_expected_time_for(parameter="resolution", service_level=priority, start_date_time=start_date_time) - - if meta.has_field("resolution_by_variance") and not doc.get("resolution_date"): - now_time = frappe.flags.current_time or now_datetime(doc.get("owner")) - doc.resolution_by_variance = round(time_diff_in_seconds(doc.resolution_by, now_time), 2) - - def now_datetime(user): dt = convert_utc_to_user_timezone(datetime.utcnow(), user) return dt.replace(tzinfo=None) From 214d0e367f5290dd82bc2f1dc7cdbd8e02951283 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Fri, 26 Nov 2021 20:20:38 +0530 Subject: [PATCH 13/77] fix: remove leading whitespace --- .../doctype/service_level_agreement/service_level_agreement.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.js b/erpnext/support/doctype/service_level_agreement/service_level_agreement.js index 7e260dbbf5..bfbffe22ad 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.js +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.js @@ -47,7 +47,7 @@ frappe.ui.form.on('Service Level Agreement', { entity: function(frm) { const field = frm.get_field('entity'); if (frm.doc.entity) { - const and_descendants = frm.doc.entity_type != 'Customer' ? __(' or its descendants') : ''; + const and_descendants = frm.doc.entity_type != 'Customer' ? ' ' + __('or its descendants') : ''; field.set_description( __('SLA will be applied if {1} is set as {2}{3}', [ frm.doc.document_type, frm.doc.entity_type, From 267cc3585013c5a9454df0c25b65d90bf8fef171 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Sat, 27 Nov 2021 16:47:45 +0530 Subject: [PATCH 14/77] fix: failing tests --- .../service_level_agreement/service_level_agreement.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py index 864640204b..565f05083b 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py @@ -642,8 +642,8 @@ def reset_expected_response_and_resolution(doc): def set_response_by_and_variance(doc, start_date_time, priority): if doc.meta.has_field("response_by"): doc.response_by = get_expected_time_for(parameter="response", service_level=priority, start_date_time=start_date_time) - if doc.meta.has_field("total_hold_time") and doc.total_hold_time: - doc.response_by = add_to_date(doc.response_by, seconds=round(doc.total_hold_time)) + if doc.meta.has_field("total_hold_time") and doc.get('total_hold_time'): + doc.response_by = add_to_date(doc.response_by, seconds=round(doc.get('total_hold_time'))) if doc.meta.has_field("response_by_variance") and not doc.get('first_responded_on'): now_time = frappe.flags.current_time or now_datetime(doc.get("owner")) @@ -656,8 +656,8 @@ def set_response_by_and_variance(doc, start_date_time, priority): def set_resolution_by_and_variance(doc, start_date_time, priority): if doc.meta.has_field("resolution_by"): doc.resolution_by = get_expected_time_for(parameter="resolution", service_level=priority, start_date_time=start_date_time) - if doc.meta.has_field("total_hold_time") and doc.total_hold_time: - doc.resolution_by = add_to_date(doc.resolution_by, seconds=round(doc.total_hold_time)) + if doc.meta.has_field("total_hold_time") and doc.get('total_hold_time'): + doc.resolution_by = add_to_date(doc.resolution_by, seconds=round(doc.get('total_hold_time'))) if doc.meta.has_field("resolution_by_variance") and not doc.get("resolution_date"): now_time = frappe.flags.current_time or now_datetime(doc.get("owner")) From 79f8159ab95e357aa7b094901a9da36a44281c1c Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Sat, 27 Nov 2021 17:14:58 +0530 Subject: [PATCH 15/77] test: issue closing after being on hold --- erpnext/support/doctype/issue/test_issue.py | 26 +++++++++++++++++++ .../service_level_agreement.py | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/erpnext/support/doctype/issue/test_issue.py b/erpnext/support/doctype/issue/test_issue.py index ab9a444bc3..0559b15649 100644 --- a/erpnext/support/doctype/issue/test_issue.py +++ b/erpnext/support/doctype/issue/test_issue.py @@ -142,6 +142,32 @@ class TestIssue(TestSetUp): issue.reload() self.assertEqual(flt(issue.total_hold_time, 2), 2700) + def test_issue_close_after_on_hold(self): + creation = get_datetime("2021-11-01 19:00") + + issue = make_issue(creation, index=1) + create_communication(issue.name, "test@example.com", "Received", creation) + + # send a reply within SLA + creation = get_datetime("2021-11-02 11:00") + create_communication(issue.name, "test@admin.com", "Sent", creation) + + frappe.flags.current_time = creation + issue.reload() + issue.status = 'Replied' + issue.save() + + self.assertEqual(issue.on_hold_since, frappe.flags.current_time) + + # close the issue after being on hold for 20 days + frappe.flags.current_time = get_datetime("2021-11-22 01:00") + issue.status = 'Closed' + issue.save() + + self.assertEqual(issue.resolution_by, get_datetime('2021-11-22 06:00:00')) + self.assertEqual(issue.resolution_date, get_datetime('2021-11-22 01:00:00')) + self.assertEqual(issue.agreement_status, 'Fulfilled') + class TestFirstResponseTime(TestSetUp): # working hours used in all cases: Mon-Fri, 10am to 6pm # all dates are in the mm-dd-yyyy format diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py index 565f05083b..9c1e536078 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py @@ -642,7 +642,7 @@ def reset_expected_response_and_resolution(doc): def set_response_by_and_variance(doc, start_date_time, priority): if doc.meta.has_field("response_by"): doc.response_by = get_expected_time_for(parameter="response", service_level=priority, start_date_time=start_date_time) - if doc.meta.has_field("total_hold_time") and doc.get('total_hold_time'): + if doc.meta.has_field("total_hold_time") and doc.get('total_hold_time') and not doc.get('first_responded_on'): doc.response_by = add_to_date(doc.response_by, seconds=round(doc.get('total_hold_time'))) if doc.meta.has_field("response_by_variance") and not doc.get('first_responded_on'): From 6f1cf94c9fb2c7f7abda9802c4868b427a339ed9 Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Thu, 2 Dec 2021 01:18:30 +0530 Subject: [PATCH 16/77] fix: Fix 'Adjust Asset Value' button --- erpnext/assets/doctype/asset/asset.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index c0c437f8d2..a053c7fab9 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -724,7 +724,7 @@ def create_asset_repair(asset, asset_name): @frappe.whitelist() def create_asset_adjustment(asset, asset_category, company): - asset_maintenance = frappe.get_doc("Asset Value Adjustment") + asset_maintenance = frappe.new_doc("Asset Value Adjustment") asset_maintenance.update({ "asset": asset, "company": company, From 5b224f841b0419d0250f02549300e0754fa816b5 Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Thu, 2 Dec 2021 01:19:35 +0530 Subject: [PATCH 17/77] fix: Rename variable --- erpnext/assets/doctype/asset/asset.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index a053c7fab9..ff5964be68 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -724,13 +724,13 @@ def create_asset_repair(asset, asset_name): @frappe.whitelist() def create_asset_adjustment(asset, asset_category, company): - asset_maintenance = frappe.new_doc("Asset Value Adjustment") - asset_maintenance.update({ + asset_value_adjustment = frappe.new_doc("Asset Value Adjustment") + asset_value_adjustment.update({ "asset": asset, "company": company, "asset_category": asset_category }) - return asset_maintenance + return asset_value_adjustment @frappe.whitelist() def transfer_asset(args): From 4629308d94b4fc2a667a7eb7d526b2a810bb486a Mon Sep 17 00:00:00 2001 From: GangaManoj Date: Thu, 2 Dec 2021 01:22:18 +0530 Subject: [PATCH 18/77] fix: Rename function --- erpnext/assets/doctype/asset/asset.js | 6 +++--- erpnext/assets/doctype/asset/asset.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/erpnext/assets/doctype/asset/asset.js b/erpnext/assets/doctype/asset/asset.js index da5778ea3d..9fa50a9ade 100644 --- a/erpnext/assets/doctype/asset/asset.js +++ b/erpnext/assets/doctype/asset/asset.js @@ -110,7 +110,7 @@ frappe.ui.form.on('Asset', { if (frm.doc.status != 'Fully Depreciated') { frm.add_custom_button(__("Adjust Asset Value"), function() { - frm.trigger("create_asset_adjustment"); + frm.trigger("create_asset_value_adjustment"); }, __("Manage")); } @@ -322,14 +322,14 @@ frappe.ui.form.on('Asset', { }); }, - create_asset_adjustment: function(frm) { + create_asset_value_adjustment: function(frm) { frappe.call({ args: { "asset": frm.doc.name, "asset_category": frm.doc.asset_category, "company": frm.doc.company }, - method: "erpnext.assets.doctype.asset.asset.create_asset_adjustment", + method: "erpnext.assets.doctype.asset.asset.create_asset_value_adjustment", freeze: 1, callback: function(r) { var doclist = frappe.model.sync(r.message); diff --git a/erpnext/assets/doctype/asset/asset.py b/erpnext/assets/doctype/asset/asset.py index ff5964be68..f2b02cb9c5 100644 --- a/erpnext/assets/doctype/asset/asset.py +++ b/erpnext/assets/doctype/asset/asset.py @@ -723,7 +723,7 @@ def create_asset_repair(asset, asset_name): return asset_repair @frappe.whitelist() -def create_asset_adjustment(asset, asset_category, company): +def create_asset_value_adjustment(asset, asset_category, company): asset_value_adjustment = frappe.new_doc("Asset Value Adjustment") asset_value_adjustment.update({ "asset": asset, From 6f67bbca69a1dcd7a9dd9918f0c656704ecc14a7 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Thu, 2 Dec 2021 13:39:05 +0530 Subject: [PATCH 19/77] refactor(SLA): remove response_by_variance & resolution_by_variance --- erpnext/support/doctype/issue/issue.json | 22 +- erpnext/support/doctype/issue/issue.py | 4 +- erpnext/support/doctype/issue/test_issue.py | 109 +++++-- .../service_level_agreement.py | 269 ++++++++---------- .../test_service_level_agreement.py | 68 ++--- 5 files changed, 221 insertions(+), 251 deletions(-) diff --git a/erpnext/support/doctype/issue/issue.json b/erpnext/support/doctype/issue/issue.json index 75b6d0f4f9..1da22fd58f 100644 --- a/erpnext/support/doctype/issue/issue.json +++ b/erpnext/support/doctype/issue/issue.json @@ -24,12 +24,10 @@ "service_level_section", "service_level_agreement", "response_by", - "response_by_variance", "reset_service_level_agreement", "cb", "agreement_status", "resolution_by", - "resolution_by_variance", "service_level_agreement_creation", "on_hold_since", "total_hold_time", @@ -44,8 +42,6 @@ "opening_date", "opening_time", "resolution_date", - "resolution_time", - "user_resolution_time", "additional_info", "lead", "contact", @@ -317,22 +313,6 @@ "fieldtype": "Check", "label": "Via Customer Portal" }, - { - "depends_on": "eval: doc.service_level_agreement && doc.status != 'Replied';", - "fieldname": "response_by_variance", - "fieldtype": "Duration", - "hide_seconds": 1, - "label": "Response By Variance", - "read_only": 1 - }, - { - "depends_on": "eval: doc.service_level_agreement && doc.status != 'Replied';", - "fieldname": "resolution_by_variance", - "fieldtype": "Duration", - "hide_seconds": 1, - "label": "Resolution By Variance", - "read_only": 1 - }, { "fieldname": "service_level_agreement_creation", "fieldtype": "Datetime", @@ -395,7 +375,7 @@ "fieldname": "agreement_status", "fieldtype": "Select", "label": "Service Level Agreement Status", - "options": "Ongoing\nFulfilled\nFailed", + "options": "First Response Due\nResolution Due\nFulfilled\nFailed", "read_only": 1 }, { diff --git a/erpnext/support/doctype/issue/issue.py b/erpnext/support/doctype/issue/issue.py index 0dc3639f1e..d5e5b78288 100644 --- a/erpnext/support/doctype/issue/issue.py +++ b/erpnext/support/doctype/issue/issue.py @@ -87,11 +87,9 @@ class Issue(Document): if replicated_issue.service_level_agreement: replicated_issue.service_level_agreement_creation = now_datetime() replicated_issue.service_level_agreement = None - replicated_issue.agreement_status = "Ongoing" + replicated_issue.agreement_status = "First Response Due" replicated_issue.response_by = None - replicated_issue.response_by_variance = None replicated_issue.resolution_by = None - replicated_issue.resolution_by_variance = None replicated_issue.reset_issue_metrics() frappe.get_doc(replicated_issue).insert() diff --git a/erpnext/support/doctype/issue/test_issue.py b/erpnext/support/doctype/issue/test_issue.py index 0559b15649..da9953df55 100644 --- a/erpnext/support/doctype/issue/test_issue.py +++ b/erpnext/support/doctype/issue/test_issue.py @@ -83,30 +83,6 @@ class TestIssue(TestSetUp): self.assertEqual(issue.agreement_status, 'Fulfilled') - def test_issue_metrics(self): - creation = get_datetime("2020-03-04 4:00") - - issue = make_issue(creation, index=1) - create_communication(issue.name, "test@example.com", "Received", creation) - - creation = get_datetime("2020-03-04 4:15") - create_communication(issue.name, "test@admin.com", "Sent", creation) - - creation = get_datetime("2020-03-04 5:00") - create_communication(issue.name, "test@example.com", "Received", creation) - - creation = get_datetime("2020-03-04 5:05") - create_communication(issue.name, "test@admin.com", "Sent", creation) - - frappe.flags.current_time = get_datetime("2020-03-04 5:05") - issue.reload() - issue.status = 'Closed' - issue.save() - - self.assertEqual(issue.avg_response_time, 600) - self.assertEqual(issue.resolution_time, 3900) - self.assertEqual(issue.user_resolution_time, 1200) - def test_hold_time_on_replied(self): creation = get_datetime("2020-03-04 4:00") @@ -143,16 +119,15 @@ class TestIssue(TestSetUp): self.assertEqual(flt(issue.total_hold_time, 2), 2700) def test_issue_close_after_on_hold(self): - creation = get_datetime("2021-11-01 19:00") + frappe.flags.current_time = get_datetime("2021-11-01 19:00") - issue = make_issue(creation, index=1) - create_communication(issue.name, "test@example.com", "Received", creation) + issue = make_issue(frappe.flags.current_time, index=1) + create_communication(issue.name, "test@example.com", "Received", frappe.flags.current_time) # send a reply within SLA - creation = get_datetime("2021-11-02 11:00") - create_communication(issue.name, "test@admin.com", "Sent", creation) + frappe.flags.current_time = get_datetime("2021-11-02 11:00") + create_communication(issue.name, "test@admin.com", "Sent", frappe.flags.current_time) - frappe.flags.current_time = creation issue.reload() issue.status = 'Replied' issue.save() @@ -168,6 +143,75 @@ class TestIssue(TestSetUp): self.assertEqual(issue.resolution_date, get_datetime('2021-11-22 01:00:00')) self.assertEqual(issue.agreement_status, 'Fulfilled') + def test_issue_open_after_closed(self): + + # Created on -> 1 pm, Response Time -> 4 hrs, Resolution Time -> 6 hrs + frappe.flags.current_time = get_datetime("2021-11-01 13:00") + issue = make_issue(frappe.flags.current_time, index=1, issue_type='Critical') # Applies 24hr working time SLA + create_communication(issue.name, "test@example.com", "Received", frappe.flags.current_time) + self.assertEquals(issue.agreement_status, 'First Response Due') + self.assertEquals(issue.response_by, get_datetime("2021-11-01 17:00")) + self.assertEquals(issue.resolution_by, get_datetime("2021-11-01 19:00")) + + # Replied on → 2 pm + frappe.flags.current_time = get_datetime("2021-11-01 14:00") + create_communication(issue.name, "test@admin.com", "Sent", frappe.flags.current_time) + issue.reload() + issue.status = 'Replied' + issue.save() + self.assertEquals(issue.agreement_status, 'Resolution Due') + self.assertEquals(issue.on_hold_since, frappe.flags.current_time) + self.assertEquals(issue.first_responded_on, frappe.flags.current_time) + + # Customer Replied → 3 pm + frappe.flags.current_time = get_datetime("2021-11-01 15:00") + create_communication(issue.name, "test@example.com", "Received", frappe.flags.current_time) + issue.reload() + self.assertEquals(issue.status, 'Open') + # Hold Time + 1 Hrs + self.assertEquals(issue.total_hold_time, 3600) + # Resolution By should increase by one hrs + self.assertEquals(issue.resolution_by, get_datetime("2021-11-01 20:00")) + + # Replied on → 4 pm, Open → 1 hr, Resolution Due → 8 pm + frappe.flags.current_time = get_datetime("2021-11-01 16:00") + create_communication(issue.name, "test@admin.com", "Sent", frappe.flags.current_time) + issue.reload() + issue.status = 'Replied' + issue.save() + self.assertEquals(issue.agreement_status, 'Resolution Due') + + # Customer Closed → 10 pm + frappe.flags.current_time = get_datetime("2021-11-01 22:00") + issue.status = 'Closed' + issue.save() + # Hold Time + 6 Hrs + self.assertEquals(issue.total_hold_time, 3600 + 21600) + # Resolution By should increase by 6 hrs + self.assertEquals(issue.resolution_by, get_datetime("2021-11-02 02:00")) + self.assertEquals(issue.agreement_status, 'Fulfilled') + self.assertEquals(issue.resolution_date, frappe.flags.current_time) + + # Customer Open → 3 am i.e after resolution by is crossed + frappe.flags.current_time = get_datetime("2021-11-02 03:00") + create_communication(issue.name, "test@example.com", "Received", frappe.flags.current_time) + issue.reload() + # Since issue was Resolved, Resolution By should be increased by 5 hrs (3am - 10pm) + self.assertEquals(issue.total_hold_time, 3600 + 21600 + 18000) + # Resolution By should increase by 5 hrs + self.assertEquals(issue.resolution_by, get_datetime("2021-11-02 07:00")) + self.assertEquals(issue.agreement_status, 'Resolution Due') + self.assertFalse(issue.resolution_date) + + # We Closed → 4 am, SLA should be Fulfilled + frappe.flags.current_time = get_datetime("2021-11-02 04:00") + issue.status = 'Closed' + issue.save() + self.assertEquals(issue.resolution_by, get_datetime("2021-11-02 07:00")) + self.assertEquals(issue.agreement_status, 'Fulfilled') + self.assertEquals(issue.resolution_date, frappe.flags.current_time) + + class TestFirstResponseTime(TestSetUp): # working hours used in all cases: Mon-Fri, 10am to 6pm # all dates are in the mm-dd-yyyy format @@ -386,7 +430,10 @@ def create_issue_and_communication(issue_creation, first_responded_on): return issue -def make_issue(creation=None, customer=None, index=0, priority=None, issue_type=None): +def make_issue(creation=None, customer=None, index=0, priority=None, issue_type=None, do_not_insert=False): + if not frappe.db.exists('Issue Type', issue_type): + frappe.get_doc(dict(doctype='Issue Type', name=issue_type)).insert() + issue = frappe.get_doc({ "doctype": "Issue", "subject": "Service Level Agreement Issue {0}".format(index), diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py index 9c1e536078..e2326ac2df 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py @@ -358,36 +358,102 @@ def apply(doc, method=None): ): return - service_level_agreement = get_active_service_level_agreement_for(doc) + sla = get_active_service_level_agreement_for(doc) - if not service_level_agreement: + if not sla: return - process_sla(doc, service_level_agreement) + process_sla(doc, sla) -def process_sla(doc, service_level_agreement): +def process_sla(doc, sla): if not doc.creation: doc.creation = now_datetime(doc.get("owner")) if doc.meta.has_field("service_level_agreement_creation"): doc.service_level_agreement_creation = now_datetime(doc.get("owner")) - doc.service_level_agreement = service_level_agreement.name - doc.priority = doc.get("priority") or service_level_agreement.default_priority + doc.service_level_agreement = sla.name + doc.priority = doc.get("priority") or sla.default_priority prev_status = frappe.db.get_value(doc.doctype, doc.name, 'status') - handle_status_change(doc, prev_status, service_level_agreement.apply_sla_for_resolution) - update_response_and_resolution_metrics(doc, service_level_agreement.apply_sla_for_resolution) - update_agreement_status(doc, service_level_agreement.apply_sla_for_resolution) + handle_status_change(doc, prev_status, sla.apply_sla_for_resolution) + update_response_and_resolution_metrics(doc, sla.apply_sla_for_resolution) + update_agreement_status(doc, sla.apply_sla_for_resolution) -def update_response_and_resolution_metrics(doc, apply_sla_for_resolution): - priority = get_response_and_resolution_duration(doc) - start_date_time = get_datetime(doc.get("service_level_agreement_creation") or doc.creation) - set_response_by_and_variance(doc, start_date_time, priority) - if apply_sla_for_resolution: - set_resolution_by_and_variance(doc, start_date_time, priority) +def handle_status_change(doc, prev_status, apply_sla_for_resolution): + now_time = frappe.flags.current_time or now_datetime(doc.get("owner")) + + hold_statuses = get_hold_statuses(doc.service_level_agreement) + fulfillment_statuses = get_fulfillment_statuses(doc.service_level_agreement) + + def is_hold_status(status): + return status in hold_statuses + + def is_fulfilled_status(status): + return status in fulfillment_statuses + + def is_open_status(status): + return status not in hold_statuses and status not in fulfillment_statuses + + def calculate_hold_hours(): + # In case issue was closed and after few days it has been opened + # The hold time should be calculated from resolution_date + + on_hold_since = doc.resolution_date or doc.on_hold_since + if on_hold_since: + current_hold_hours = time_diff_in_seconds(now_time, on_hold_since) + doc.total_hold_time = (doc.total_hold_time or 0) + current_hold_hours + doc.on_hold_since = None + + if is_open_status(prev_status) and not is_open_status(doc.status): + # status changed from Open to something else + if doc.meta.has_field("first_responded_on") and not doc.first_responded_on: + doc.first_responded_on = now_time + + # Open to Replied + if is_open_status(prev_status) and is_hold_status(doc.status): + # Issue is on hold -> Set on_hold_since + doc.on_hold_since = now_time + + # Replied to Open + if is_hold_status(prev_status) and is_open_status(doc.status): + # Issue was on hold -> Calculate Total Hold Time + calculate_hold_hours() + # Issue is open -> reset resolution_date + reset_expected_response_and_resolution(doc) + reset_resolution_metrics(doc) + + # Open to Closed + if is_open_status(prev_status) and is_fulfilled_status(doc.status): + # Issue is closed -> Set resolution_date + doc.resolution_date = now_time + set_resolution_time(doc) + + # Closed to Open + if is_fulfilled_status(prev_status) and is_open_status(doc.status): + # Issue was closed -> Calculate Total Hold Time from resolution_date + calculate_hold_hours() + # Issue is open -> reset resolution_date + reset_expected_response_and_resolution(doc) + reset_resolution_metrics(doc) + + # Closed to Replied + if is_fulfilled_status(prev_status) and is_hold_status(doc.status): + # Issue was closed -> Calculate Total Hold Time from resolution_date + calculate_hold_hours() + # Issue is on hold -> Set on_hold_since + doc.on_hold_since = now_time + + # Replied to Closed + if is_hold_status(prev_status) and is_fulfilled_status(doc.status): + # Issue was on hold -> Calculate Total Hold Time + calculate_hold_hours() + # Issue is closed -> Set resolution_date + if apply_sla_for_resolution: + doc.resolution_date = now_time + set_resolution_time(doc) def get_fulfillment_statuses(service_level_agreement): @@ -402,60 +468,12 @@ def get_hold_statuses(service_level_agreement): }, fields=["status"])] -def handle_status_change(doc, prev_status, apply_sla_for_resolution): - - if doc.status != "Open" and prev_status == "Open": - # status changed from Open to something else - if doc.meta.has_field("first_responded_on") and not doc.first_responded_on: - # status changed to something other than Open - doc.first_responded_on = frappe.flags.current_time or now_datetime(doc.get("owner")) - - if doc.status == "Open" and prev_status != "Open": - # status changed from something else to Open - reset_resolution_metrics(doc) - - handle_fulfillment_status(doc, prev_status, apply_sla_for_resolution) - handle_hold_status(doc, prev_status) - - -def handle_fulfillment_status(doc, prev_status, apply_sla_for_resolution): - fulfillment_statuses = get_fulfillment_statuses(doc.service_level_agreement) - if ( - doc.status in fulfillment_statuses - and prev_status not in fulfillment_statuses - and apply_sla_for_resolution - ): - # status changed to any fulfillment_statuses - if doc.meta.has_field("resolution_date"): - doc.resolution_date = frappe.flags.current_time or now_datetime(doc.get("owner")) - if doc.meta.has_field("resolution_time"): - doc.resolution_time = time_diff_in_seconds(doc.resolution_date, doc.creation) - set_user_resolution_time(doc) - - -def handle_hold_status(doc, prev_status): - hold_statuses = get_hold_statuses(doc.service_level_agreement) - if doc.status in hold_statuses: - # reset if status is a hold status, regardless of previous status - reset_expected_response_and_resolution(doc) - if prev_status not in hold_statuses: - # set on_hold_since status changed from any non-hold status - # for eg. doc.status changed from Open to Replied - if doc.meta.has_field("on_hold_since"): - doc.on_hold_since = frappe.flags.current_time or now_datetime(doc.get("owner")) - - if doc.status not in hold_statuses and prev_status in hold_statuses: - # status changed to any non-hold status - # for eg. doc.status changed from Replied to Closed - if doc.meta.has_field("on_hold_since") and doc.on_hold_since: - cumulate_hold_time(doc) - doc.on_hold_since = None - - -def cumulate_hold_time(doc): - now_time = frappe.flags.current_time or now_datetime(doc.get("owner")) - on_hold_duration = time_diff_in_seconds(now_time, doc.on_hold_since) - doc.total_hold_time = (doc.total_hold_time or 0) + on_hold_duration +def update_response_and_resolution_metrics(doc, apply_sla_for_resolution): + priority = get_response_and_resolution_duration(doc) + start_date_time = get_datetime(doc.get("service_level_agreement_creation") or doc.creation) + set_response_by(doc, start_date_time, priority) + if apply_sla_for_resolution: + set_resolution_by(doc, start_date_time, priority) def get_expected_time_for(parameter, service_level, start_date_time): @@ -526,7 +544,11 @@ def get_support_days(service_level): return support_days -def set_user_resolution_time(doc): +def set_resolution_time(doc): + start_date_time = get_datetime(doc.get("service_level_agreement_creation") or doc.creation) + if doc.meta.has_field("resolution_time"): + doc.resolution_time = time_diff_in_seconds(doc.resolution_date, start_date_time) + # total time taken by a user to close the issue apart from wait_time if not doc.meta.has_field("user_resolution_time"): return @@ -544,7 +566,7 @@ def set_user_resolution_time(doc): pending_time.append(wait_time) total_pending_time = sum(pending_time) - resolution_time_in_secs = time_diff_in_seconds(doc.resolution_date, doc.creation) + resolution_time_in_secs = time_diff_in_seconds(doc.resolution_date, start_date_time) doc.user_resolution_time = resolution_time_in_secs - total_pending_time @@ -562,11 +584,11 @@ def change_service_level_agreement_and_priority(self): def get_response_and_resolution_duration(doc): - service_level_agreement = frappe.get_doc("Service Level Agreement", doc.service_level_agreement) - priority = service_level_agreement.get_service_level_agreement_priority(doc.priority) + sla = frappe.get_doc("Service Level Agreement", doc.service_level_agreement) + priority = sla.get_service_level_agreement_priority(doc.priority) priority.update({ - "support_and_resolution": service_level_agreement.support_and_resolution, - "holiday_list": service_level_agreement.holiday_list + "support_and_resolution": sla.support_and_resolution, + "holiday_list": sla.holiday_list }) return priority @@ -585,8 +607,6 @@ def reset_service_level_agreement(doc, reason, user): }).insert(ignore_permissions=True) doc.service_level_agreement_creation = now_datetime(doc.get("owner")) - doc.set_response_and_resolution_time(priority=doc.priority, service_level_agreement=doc.service_level_agreement) - doc.agreement_status = "Ongoing" doc.save() @@ -616,56 +636,37 @@ def update_hold_time(doc, status): if not parent.meta.has_field('service_level_agreement'): return - apply_sla_for_resolution = frappe.db.get_value('Service Level Agreement', parent.service_level_agreement, 'apply_sla_for_resolution') + for_resolution = frappe.db.get_value('Service Level Agreement', parent.service_level_agreement, 'apply_sla_for_resolution') - handle_status_change(parent, 'Replied', apply_sla_for_resolution) - update_response_and_resolution_metrics(parent, apply_sla_for_resolution) - update_agreement_status(parent, apply_sla_for_resolution) + handle_status_change(parent, 'Replied', for_resolution) + update_response_and_resolution_metrics(parent, for_resolution) + update_agreement_status(parent, for_resolution) parent.save() def reset_expected_response_and_resolution(doc): update_values = {} - if doc.meta.has_field("first_responded_on") and not doc.first_responded_on: update_values['response_by'] = None - update_values['response_by_variance'] = 0 - if doc.meta.has_field("resolution_by") and not doc.resolution_date: update_values['resolution_by'] = None - update_values['resolution_by_variance'] = 0 - doc.db_set(update_values) -def set_response_by_and_variance(doc, start_date_time, priority): +def set_response_by(doc, start_date_time, priority): if doc.meta.has_field("response_by"): doc.response_by = get_expected_time_for(parameter="response", service_level=priority, start_date_time=start_date_time) if doc.meta.has_field("total_hold_time") and doc.get('total_hold_time') and not doc.get('first_responded_on'): doc.response_by = add_to_date(doc.response_by, seconds=round(doc.get('total_hold_time'))) - if doc.meta.has_field("response_by_variance") and not doc.get('first_responded_on'): - now_time = frappe.flags.current_time or now_datetime(doc.get("owner")) - doc.response_by_variance = round(time_diff_in_seconds(doc.response_by, now_time), 2) - if doc.meta.has_field("response_by_variance") and doc.get('first_responded_on'): - doc.response_by_variance = round(time_diff_in_seconds(doc.response_by, doc.get('first_responded_on')), 2) - - -def set_resolution_by_and_variance(doc, start_date_time, priority): +def set_resolution_by(doc, start_date_time, priority): if doc.meta.has_field("resolution_by"): doc.resolution_by = get_expected_time_for(parameter="resolution", service_level=priority, start_date_time=start_date_time) if doc.meta.has_field("total_hold_time") and doc.get('total_hold_time'): doc.resolution_by = add_to_date(doc.resolution_by, seconds=round(doc.get('total_hold_time'))) - if doc.meta.has_field("resolution_by_variance") and not doc.get("resolution_date"): - now_time = frappe.flags.current_time or now_datetime(doc.get("owner")) - doc.resolution_by_variance = round(time_diff_in_seconds(doc.resolution_by, now_time), 2) - - if doc.meta.has_field("resolution_by_variance") and doc.get('resolution_date'): - doc.resolution_by_variance = round(time_diff_in_seconds(doc.resolution_by, doc.get('resolution_date')), 2) - def get_service_level_agreement_fields(): return [ @@ -693,17 +694,11 @@ def get_service_level_agreement_fields(): "label": "Response By", "read_only": 1 }, - { - "fieldname": "response_by_variance", - "fieldtype": "Duration", - "hide_seconds": 1, - "label": "Response By Variance", - "read_only": 1 - }, { "fieldname": "first_responded_on", "fieldtype": "Datetime", "label": "First Responded On", + "no_copy": 1, "read_only": 1 }, { @@ -725,11 +720,11 @@ def get_service_level_agreement_fields(): "read_only": 1 }, { - "default": "Ongoing", + "default": "First Response Due", "fieldname": "agreement_status", "fieldtype": "Select", "label": "Service Level Agreement Status", - "options": "Ongoing\nFulfilled\nFailed", + "options": "First Response Due\nResolution Due\nFulfilled\nFailed", "read_only": 1 }, { @@ -738,13 +733,6 @@ def get_service_level_agreement_fields(): "label": "Resolution By", "read_only": 1 }, - { - "fieldname": "resolution_by_variance", - "fieldtype": "Duration", - "hide_seconds": 1, - "label": "Resolution By Variance", - "read_only": 1 - }, { "fieldname": "service_level_agreement_creation", "fieldtype": "Datetime", @@ -765,43 +753,28 @@ def get_service_level_agreement_fields(): def update_agreement_status_on_custom_status(doc): # Update Agreement Fulfilled status using Custom Scripts for Custom Status - - meta = frappe.get_meta(doc.doctype) - now_time = frappe.flags.current_time or now_datetime(doc.get("owner")) - if doc.meta.has_field("first_responded_on") and not doc.first_responded_on: - # first_responded_on set when first reply is sent to customer - doc.response_by_variance = round(time_diff_in_seconds(doc.response_by, now_time), 2) - - if doc.meta.has_field("first_responded_on") and doc.first_responded_on: - # first_responded_on set when first reply is sent to customer - doc.response_by_variance = round(time_diff_in_seconds(doc.response_by, doc.first_responded_on), 2) - - if doc.meta.has_field("resolution_date") and not doc.resolution_date: - # resolution_date set when issue has been closed - doc.resolution_by_variance = round(time_diff_in_seconds(doc.resolution_by, now_time), 2) - - if doc.meta.has_field("resolution_date") and doc.resolution_date: - # resolution_date set when issue has been closed - doc.resolution_by_variance = round(time_diff_in_seconds(doc.resolution_by, doc.resolution_date), 2) - - if doc.meta.has_field("agreement_status"): - doc.agreement_status = "Fulfilled" if doc.response_by_variance > 0 and doc.resolution_by_variance > 0 else "Failed" + update_agreement_status(doc) def update_agreement_status(doc, apply_sla_for_resolution): if (doc.meta.has_field("agreement_status")): # if SLA is applied for resolution check for response and resolution, else only response if apply_sla_for_resolution: - if doc.meta.has_field("response_by_variance") and doc.meta.has_field("resolution_by_variance"): - if doc.response_by_variance < 0 or doc.resolution_by_variance < 0: - doc.agreement_status = "Failed" - else: - doc.agreement_status = "Fulfilled" - else: - if doc.meta.has_field("response_by_variance") and doc.response_by_variance < 0: - doc.agreement_status = "Failed" - else: + if not doc.first_responded_on: + doc.agreement_status = "First Response Due" + elif not doc.resolution_date: + doc.agreement_status = "Resolution Due" + elif get_datetime(doc.resolution_date) <= get_datetime(doc.resolution_by): doc.agreement_status = "Fulfilled" + else: + doc.agreement_status = "Failed" + else: + if not doc.first_responded_on: + doc.agreement_status = "First Response Due" + elif get_datetime(doc.first_responded_on) <= get_datetime(doc.response_by): + doc.agreement_status = "Fulfilled" + else: + doc.agreement_status = "Failed" def is_holiday(date, holidays): diff --git a/erpnext/support/doctype/service_level_agreement/test_service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/test_service_level_agreement.py index cfbe7446c0..ce564c4dae 100644 --- a/erpnext/support/doctype/service_level_agreement/test_service_level_agreement.py +++ b/erpnext/support/doctype/service_level_agreement/test_service_level_agreement.py @@ -220,42 +220,6 @@ class TestServiceLevelAgreement(unittest.TestCase): lead.reload() self.assertEqual(lead.agreement_status, 'Fulfilled') - def test_changing_of_variance_after_response(self): - # create lead - doctype = "Lead" - lead_sla = create_service_level_agreement( - default_service_level_agreement=1, - holiday_list="__Test Holiday List", - entity_type=None, entity=None, - response_time=14400, - doctype=doctype, - sla_fulfilled_on=[{"status": "Replied"}], - apply_sla_for_resolution=0 - ) - creation = datetime.datetime(2019, 3, 4, 12, 0) - lead = make_lead(creation=creation, index=2) - self.assertEqual(lead.service_level_agreement, lead_sla.name) - - # set lead as replied to set first responded on - frappe.flags.current_time = datetime.datetime(2019, 3, 4, 15, 30) - lead.reload() - lead.status = 'Replied' - lead.save() - lead.reload() - self.assertEqual(lead.agreement_status, 'Fulfilled') - - # check response_by_variance - self.assertEqual(lead.first_responded_on, frappe.flags.current_time) - self.assertEqual(lead.response_by_variance, 1800.0) - - # make a change on the document & - # check response_by_variance is unchanged - frappe.flags.current_time = datetime.datetime(2019, 3, 4, 18, 30) - lead.status = 'Open' - lead.save() - lead.reload() - self.assertEqual(lead.response_by_variance, 1800.0) - def test_service_level_agreement_filters(self): doctype = "Lead" lead_sla = create_service_level_agreement( @@ -295,7 +259,8 @@ def get_service_level_agreement(default_service_level_agreement=None, entity_typ return service_level_agreement def create_service_level_agreement(default_service_level_agreement, holiday_list, response_time, entity_type, - entity, resolution_time=0, doctype="Issue", condition="", sla_fulfilled_on=[], pause_sla_on=[], apply_sla_for_resolution=1): + entity, resolution_time=0, doctype="Issue", condition="", sla_fulfilled_on=[], pause_sla_on=[], apply_sla_for_resolution=1, + service_level=None, start_time="10:00:00", end_time="18:00:00"): make_holiday_list() make_priorities() @@ -312,7 +277,7 @@ def create_service_level_agreement(default_service_level_agreement, holiday_list "doctype": "Service Level Agreement", "enabled": 1, "document_type": doctype, - "service_level": "__Test {} SLA".format(entity_type if entity_type else "Default"), + "service_level": service_level or "__Test {} SLA".format(entity_type if entity_type else "Default"), "default_service_level_agreement": default_service_level_agreement, "condition": condition, "default_priority": "Medium", @@ -345,28 +310,28 @@ def create_service_level_agreement(default_service_level_agreement, holiday_list "support_and_resolution": [ { "workday": "Monday", - "start_time": "10:00:00", - "end_time": "18:00:00", + "start_time": start_time, + "end_time": end_time, }, { "workday": "Tuesday", - "start_time": "10:00:00", - "end_time": "18:00:00", + "start_time": start_time, + "end_time": end_time, }, { "workday": "Wednesday", - "start_time": "10:00:00", - "end_time": "18:00:00", + "start_time": start_time, + "end_time": end_time, }, { "workday": "Thursday", - "start_time": "10:00:00", - "end_time": "18:00:00", + "start_time": start_time, + "end_time": end_time, }, { "workday": "Friday", - "start_time": "10:00:00", - "end_time": "18:00:00", + "start_time": start_time, + "end_time": end_time, } ] }) @@ -443,6 +408,13 @@ def create_service_level_agreements_for_issues(): create_service_level_agreement(default_service_level_agreement=0, holiday_list="__Test Holiday List", entity_type="Territory", entity="_Test SLA Territory", response_time=7200, resolution_time=10800) + create_service_level_agreement( + default_service_level_agreement=0, holiday_list="__Test Holiday List", + entity_type=None, entity=None, response_time=14400, resolution_time=21600, + service_level="24-hour-SLA", start_time="00:00:00", end_time="23:59:59", + condition="doc.issue_type == 'Critical'" + ) + def make_holiday_list(): holiday_list = frappe.db.exists("Holiday List", "__Test Holiday List") if not holiday_list: From a1cedc3ea027820f185c163333b69e5669cccd93 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Thu, 2 Dec 2021 14:48:24 +0530 Subject: [PATCH 20/77] fix: failing tests --- .../service_level_agreement/service_level_agreement.py | 8 ++++---- .../test_service_level_agreement.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py index e2326ac2df..62b21474f0 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py @@ -647,9 +647,9 @@ def update_hold_time(doc, status): def reset_expected_response_and_resolution(doc): update_values = {} - if doc.meta.has_field("first_responded_on") and not doc.first_responded_on: + if doc.meta.has_field("first_responded_on") and not doc.get('first_responded_on'): update_values['response_by'] = None - if doc.meta.has_field("resolution_by") and not doc.resolution_date: + if doc.meta.has_field("resolution_by") and not doc.get('resolution_date'): update_values['resolution_by'] = None doc.db_set(update_values) @@ -760,7 +760,7 @@ def update_agreement_status(doc, apply_sla_for_resolution): if (doc.meta.has_field("agreement_status")): # if SLA is applied for resolution check for response and resolution, else only response if apply_sla_for_resolution: - if not doc.first_responded_on: + if doc.meta.has_field("first_responded_on") and not doc.first_responded_on: doc.agreement_status = "First Response Due" elif not doc.resolution_date: doc.agreement_status = "Resolution Due" @@ -769,7 +769,7 @@ def update_agreement_status(doc, apply_sla_for_resolution): else: doc.agreement_status = "Failed" else: - if not doc.first_responded_on: + if doc.meta.has_field("first_responded_on") and not doc.first_responded_on: doc.agreement_status = "First Response Due" elif get_datetime(doc.first_responded_on) <= get_datetime(doc.response_by): doc.agreement_status = "Fulfilled" diff --git a/erpnext/support/doctype/service_level_agreement/test_service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/test_service_level_agreement.py index ce564c4dae..b07c862c7b 100644 --- a/erpnext/support/doctype/service_level_agreement/test_service_level_agreement.py +++ b/erpnext/support/doctype/service_level_agreement/test_service_level_agreement.py @@ -351,7 +351,7 @@ def create_service_level_agreement(default_service_level_agreement, holiday_list if sla: frappe.delete_doc("Service Level Agreement", sla, force=1) - return frappe.get_doc(service_level_agreement).insert(ignore_permissions=True) + return frappe.get_doc(service_level_agreement).insert(ignore_permissions=True, ignore_if_duplicate=True) def create_customer(): From 6736a89b4ee9b2f9125a29378700dd9a547ac19e Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Thu, 2 Dec 2021 15:54:02 +0530 Subject: [PATCH 21/77] fix: failing tests --- .../service_level_agreement.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py index 62b21474f0..662e42cc9f 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py @@ -409,7 +409,7 @@ def handle_status_change(doc, prev_status, apply_sla_for_resolution): if is_open_status(prev_status) and not is_open_status(doc.status): # status changed from Open to something else - if doc.meta.has_field("first_responded_on") and not doc.first_responded_on: + if doc.meta.has_field("first_responded_on") and not doc.get('first_responded_on'): doc.first_responded_on = now_time # Open to Replied @@ -760,18 +760,18 @@ def update_agreement_status(doc, apply_sla_for_resolution): if (doc.meta.has_field("agreement_status")): # if SLA is applied for resolution check for response and resolution, else only response if apply_sla_for_resolution: - if doc.meta.has_field("first_responded_on") and not doc.first_responded_on: + if doc.meta.has_field("first_responded_on") and not doc.get('first_responded_on'): doc.agreement_status = "First Response Due" - elif not doc.resolution_date: + elif doc.meta.has_field("resolution_date") and not doc.get('resolution_date'): doc.agreement_status = "Resolution Due" - elif get_datetime(doc.resolution_date) <= get_datetime(doc.resolution_by): + elif get_datetime(doc.get('resolution_date')) <= get_datetime(doc.get('resolution_by')): doc.agreement_status = "Fulfilled" else: doc.agreement_status = "Failed" else: - if doc.meta.has_field("first_responded_on") and not doc.first_responded_on: + if doc.meta.has_field("first_responded_on") and not doc.get('first_responded_on'): doc.agreement_status = "First Response Due" - elif get_datetime(doc.first_responded_on) <= get_datetime(doc.response_by): + elif get_datetime(doc.get('first_responded_on')) <= get_datetime(doc.get('response_by')): doc.agreement_status = "Fulfilled" else: doc.agreement_status = "Failed" From 1e550d3e46c633e24ba6f49f2883a506aa96b471 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Fri, 3 Dec 2021 11:36:53 +0530 Subject: [PATCH 22/77] fix: failing tests --- erpnext/support/doctype/issue/test_issue.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/erpnext/support/doctype/issue/test_issue.py b/erpnext/support/doctype/issue/test_issue.py index da9953df55..d566f33f24 100644 --- a/erpnext/support/doctype/issue/test_issue.py +++ b/erpnext/support/doctype/issue/test_issue.py @@ -430,9 +430,11 @@ def create_issue_and_communication(issue_creation, first_responded_on): return issue -def make_issue(creation=None, customer=None, index=0, priority=None, issue_type=None, do_not_insert=False): - if not frappe.db.exists('Issue Type', issue_type): - frappe.get_doc(dict(doctype='Issue Type', name=issue_type)).insert() +def make_issue(creation=None, customer=None, index=0, priority=None, issue_type=None): + if issue_type and not frappe.db.exists('Issue Type', issue_type): + doc = frappe.new_doc('Issue Type') + doc.name = issue_type + doc.insert() issue = frappe.get_doc({ "doctype": "Issue", From defa01edac2ebb52047f4fe060f23a9e81db0d3c Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Fri, 3 Dec 2021 16:22:10 +0530 Subject: [PATCH 23/77] fix: undo removing of resolution_time fields --- erpnext/support/doctype/issue/issue.json | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/erpnext/support/doctype/issue/issue.json b/erpnext/support/doctype/issue/issue.json index 1da22fd58f..38b5395adf 100644 --- a/erpnext/support/doctype/issue/issue.json +++ b/erpnext/support/doctype/issue/issue.json @@ -42,6 +42,8 @@ "opening_date", "opening_time", "resolution_date", + "resolution_time", + "user_resolution_time", "additional_info", "lead", "contact", @@ -345,16 +347,16 @@ "read_only": 1 }, { - "fieldname": "resolution_time", - "fieldtype": "Duration", - "label": "Resolution Time", - "read_only": 1 + "fieldname": "resolution_time", + "fieldtype": "Duration", + "label": "Resolution Time", + "read_only": 1 }, { - "fieldname": "user_resolution_time", - "fieldtype": "Duration", - "label": "User Resolution Time", - "read_only": 1 + "fieldname": "user_resolution_time", + "fieldtype": "Duration", + "label": "User Resolution Time", + "read_only": 1 }, { "fieldname": "on_hold_since", From 36a2d8ee0df17ce2f23a2afe5f63b406aaeb2ace Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 5 Dec 2021 14:42:27 +0530 Subject: [PATCH 24/77] feat: Exit Interview --- erpnext/hr/doctype/exit_interview/__init__.py | 0 .../doctype/exit_interview/exit_interview.js | 20 ++ .../exit_interview/exit_interview.json | 190 ++++++++++++++++++ .../doctype/exit_interview/exit_interview.py | 17 ++ .../exit_interview/test_exit_interview.py | 8 + 5 files changed, 235 insertions(+) create mode 100644 erpnext/hr/doctype/exit_interview/__init__.py create mode 100644 erpnext/hr/doctype/exit_interview/exit_interview.js create mode 100644 erpnext/hr/doctype/exit_interview/exit_interview.json create mode 100644 erpnext/hr/doctype/exit_interview/exit_interview.py create mode 100644 erpnext/hr/doctype/exit_interview/test_exit_interview.py diff --git a/erpnext/hr/doctype/exit_interview/__init__.py b/erpnext/hr/doctype/exit_interview/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.js b/erpnext/hr/doctype/exit_interview/exit_interview.js new file mode 100644 index 0000000000..d8ce7018d5 --- /dev/null +++ b/erpnext/hr/doctype/exit_interview/exit_interview.js @@ -0,0 +1,20 @@ +// Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('Exit Interview', { + refresh: function(frm) { + + }, + + employee: function(frm) { + frappe.db.get_value('Employee', frm.doc.employee, 'relieving_date').then(({ relieving_date }) => { + if (!relieving_date) { + frappe.throw({ + message: __('Please set the relieving date for employee {0}', + ['' + frm.doc.employee + '']), + title: __('Relieving Date Missing') + }); + } + }); + } +}); diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.json b/erpnext/hr/doctype/exit_interview/exit_interview.json new file mode 100644 index 0000000000..696820ee49 --- /dev/null +++ b/erpnext/hr/doctype/exit_interview/exit_interview.json @@ -0,0 +1,190 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "naming_series:", + "creation": "2021-12-05 13:56:36.241690", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "naming_series", + "employee", + "employee_name", + "column_break_5", + "company", + "date", + "employee_details_section", + "department", + "designation", + "reports_to", + "column_break_9", + "date_of_joining", + "relieving_date", + "exit_questionnaire_section", + "ref_doctype", + "column_break_10", + "reference_document_name", + "interview_summary_section", + "interviewers", + "text_editor_12" + ], + "fields": [ + { + "fieldname": "employee", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Employee", + "options": "Employee", + "reqd": 1 + }, + { + "fetch_from": "employee.employee_name", + "fieldname": "employee_name", + "fieldtype": "Data", + "label": "Employee Name", + "read_only": 1 + }, + { + "fetch_from": "employee.department", + "fieldname": "department", + "fieldtype": "Link", + "label": "Department", + "options": "Department", + "read_only": 1 + }, + { + "fetch_from": "employee.relieving_date", + "fieldname": "relieving_date", + "fieldtype": "Date", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Relieving Date", + "read_only": 1 + }, + { + "fieldname": "column_break_5", + "fieldtype": "Column Break" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Company", + "options": "Company", + "reqd": 1 + }, + { + "fieldname": "date", + "fieldtype": "Date", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Date", + "reqd": 1 + }, + { + "fieldname": "exit_questionnaire_section", + "fieldtype": "Section Break", + "label": "Exit Questionnaire" + }, + { + "fieldname": "ref_doctype", + "fieldtype": "Link", + "label": "Reference Document Type", + "options": "DocType" + }, + { + "fieldname": "reference_document_name", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "Reference Document Name", + "options": "ref_doctype" + }, + { + "fieldname": "interview_summary_section", + "fieldtype": "Section Break", + "label": "Interview Details" + }, + { + "fieldname": "text_editor_12", + "fieldtype": "Text Editor", + "label": "Interview Summary" + }, + { + "fieldname": "column_break_10", + "fieldtype": "Column Break" + }, + { + "fieldname": "interviewers", + "fieldtype": "Table MultiSelect", + "label": "Interviewers", + "options": "Interviewer", + "reqd": 1 + }, + { + "fetch_from": "employee.date_of_joining", + "fieldname": "date_of_joining", + "fieldtype": "Date", + "label": "Date of Joining", + "read_only": 1 + }, + { + "fieldname": "reports_to", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Reports To", + "options": "Employee", + "read_only": 1 + }, + { + "fieldname": "employee_details_section", + "fieldtype": "Section Break", + "label": "Employee Details" + }, + { + "fetch_from": "employee.designation", + "fieldname": "designation", + "fieldtype": "Link", + "label": "Designation", + "options": "Designation", + "read_only": 1 + }, + { + "fieldname": "column_break_9", + "fieldtype": "Column Break" + }, + { + "fieldname": "naming_series", + "fieldtype": "Select", + "label": "Naming Series", + "options": "HR-EXIT-INT-" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2021-12-05 14:25:40.416023", + "modified_by": "Administrator", + "module": "HR", + "name": "Exit Interview", + "naming_rule": "By \"Naming Series\" field", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "title_field": "employee_name", + "track_changes": 1 +} \ No newline at end of file diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.py b/erpnext/hr/doctype/exit_interview/exit_interview.py new file mode 100644 index 0000000000..677141b697 --- /dev/null +++ b/erpnext/hr/doctype/exit_interview/exit_interview.py @@ -0,0 +1,17 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import get_link_to_form + +class ExitInterview(Document): + def validate(self): + self.validate_relieving_date() + + def validate_relieving_date(self): + if not frappe.db.get_value('Employee', self.employee, 'relieving_date'): + frappe.throw(_('Please set the relieving date for employee {0}').format( + get_link_to_form('Employee', self.employee)), + title=_('Relieving Date Missing')) diff --git a/erpnext/hr/doctype/exit_interview/test_exit_interview.py b/erpnext/hr/doctype/exit_interview/test_exit_interview.py new file mode 100644 index 0000000000..daf3d66290 --- /dev/null +++ b/erpnext/hr/doctype/exit_interview/test_exit_interview.py @@ -0,0 +1,8 @@ +# Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt + +# import frappe +import unittest + +class TestExitInterview(unittest.TestCase): + pass From 7412accf6d4d6b932b24e40828a886b12111c1b4 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 5 Dec 2021 17:02:19 +0530 Subject: [PATCH 25/77] feat: sending Exit Questionnaire --- .../doctype/exit_interview/exit_interview.js | 38 +++++++++++++++++-- .../exit_interview/exit_interview.json | 21 +++++++++- .../doctype/exit_interview/exit_interview.py | 36 ++++++++++++++++++ .../hr/doctype/hr_settings/hr_settings.json | 29 +++++++++++++- 4 files changed, 118 insertions(+), 6 deletions(-) diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.js b/erpnext/hr/doctype/exit_interview/exit_interview.js index d8ce7018d5..31c961063f 100644 --- a/erpnext/hr/doctype/exit_interview/exit_interview.js +++ b/erpnext/hr/doctype/exit_interview/exit_interview.js @@ -3,12 +3,16 @@ frappe.ui.form.on('Exit Interview', { refresh: function(frm) { - + if (!frm.doc.__islocal && !frm.doc.questionnaire_email_sent) { + frm.add_custom_button(__('Send Exit Questionnaire'), function () { + frm.trigger('send_exit_questionnaire'); + }); + } }, employee: function(frm) { - frappe.db.get_value('Employee', frm.doc.employee, 'relieving_date').then(({ relieving_date }) => { - if (!relieving_date) { + frappe.db.get_value('Employee', frm.doc.employee, 'relieving_date', (message) => { + if (!message.relieving_date) { frappe.throw({ message: __('Please set the relieving date for employee {0}', ['' + frm.doc.employee + '']), @@ -16,5 +20,33 @@ frappe.ui.form.on('Exit Interview', { }); } }); + }, + + send_exit_questionnaire: function(frm) { + frappe.db.get_value('HR Settings', 'HR Settings', + ['exit_questionnaire_web_form', 'exit_questionnaire_notification_template'], (r) => { + if (!r.exit_questionnaire_web_form || !r.exit_questionnaire_notification_template) { + frappe.throw({ + message: __('Please set {0} and {1} in {2}.', + ['Exit Questionnaire Web Form'.bold(), + 'Notification Template'.bold(), + 'HR Settings'] + ), + title: __('Settings Missing') + }); + } else { + frappe.call({ + method: 'erpnext.hr.doctype.exit_interview.exit_interview.send_exit_questionnaire', + args: { + 'exit_interview': frm.doc.name + }, + callback: function(r) { + if (!r.exc) { + frm.refresh_field('questionnaire_email_sent'); + } + } + }); + } + }); } }); diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.json b/erpnext/hr/doctype/exit_interview/exit_interview.json index 696820ee49..0712b3d2a5 100644 --- a/erpnext/hr/doctype/exit_interview/exit_interview.json +++ b/erpnext/hr/doctype/exit_interview/exit_interview.json @@ -5,11 +5,13 @@ "creation": "2021-12-05 13:56:36.241690", "doctype": "DocType", "editable_grid": 1, + "email_append_to": 1, "engine": "InnoDB", "field_order": [ "naming_series", "employee", "employee_name", + "email", "column_break_5", "company", "date", @@ -22,6 +24,7 @@ "relieving_date", "exit_questionnaire_section", "ref_doctype", + "questionnaire_email_sent", "column_break_10", "reference_document_name", "interview_summary_section", @@ -130,6 +133,7 @@ "read_only": 1 }, { + "fetch_from": "employee.reports_to", "fieldname": "reports_to", "fieldtype": "Link", "in_standard_filter": 1, @@ -159,11 +163,25 @@ "fieldtype": "Select", "label": "Naming Series", "options": "HR-EXIT-INT-" + }, + { + "default": "0", + "fieldname": "questionnaire_email_sent", + "fieldtype": "Check", + "label": "Questionnaire Email Sent", + "read_only": 1 + }, + { + "fieldname": "email", + "fieldtype": "Data", + "label": "Email ID", + "options": "Email", + "read_only": 1 } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-12-05 14:25:40.416023", + "modified": "2021-12-05 16:50:05.933394", "modified_by": "Administrator", "module": "HR", "name": "Exit Interview", @@ -183,6 +201,7 @@ "write": 1 } ], + "sender_field": "email", "sort_field": "modified", "sort_order": "DESC", "title_field": "employee_name", diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.py b/erpnext/hr/doctype/exit_interview/exit_interview.py index 677141b697..a59146a898 100644 --- a/erpnext/hr/doctype/exit_interview/exit_interview.py +++ b/erpnext/hr/doctype/exit_interview/exit_interview.py @@ -6,12 +6,48 @@ from frappe import _ from frappe.model.document import Document from frappe.utils import get_link_to_form +from erpnext.hr.doctype.employee.employee import get_employee_email + + class ExitInterview(Document): def validate(self): self.validate_relieving_date() + self.set_employee_email() def validate_relieving_date(self): if not frappe.db.get_value('Employee', self.employee, 'relieving_date'): frappe.throw(_('Please set the relieving date for employee {0}').format( get_link_to_form('Employee', self.employee)), title=_('Relieving Date Missing')) + + def set_employee_email(self): + employee = frappe.get_doc('Employee', self.employee) + self.email = get_employee_email(employee) + + +@frappe.whitelist() +def send_exit_questionnaire(exit_interview): + exit_interview = frappe.get_doc('Exit Interview', exit_interview) + context = exit_interview.as_dict() + + employee = frappe.get_doc('Employee', exit_interview.employee) + context.update(employee.as_dict()) + + email = get_employee_email(employee) + template_name = frappe.db.get_single_value('HR Settings', 'exit_questionnaire_notification_template') + template = frappe.get_doc('Email Template', template_name) + + if email: + frappe.sendmail( + recipients=email, + subject=template.subject, + message=frappe.render_template(template.response, context), + reference_doctype=exit_interview.doctype, + reference_name=exit_interview.name + ) + frappe.msgprint(_('Exit Questionnaire sent to {0}').format(email), + title='Success', indicator='green') + exit_interview.db_set('questionnaire_email_sent', True) + exit_interview.notify_update() + else: + frappe.msgprint(_('Email IDs for employee not found.')) \ No newline at end of file diff --git a/erpnext/hr/doctype/hr_settings/hr_settings.json b/erpnext/hr/doctype/hr_settings/hr_settings.json index 5148435c13..f9a3e05fc3 100644 --- a/erpnext/hr/doctype/hr_settings/hr_settings.json +++ b/erpnext/hr/doctype/hr_settings/hr_settings.json @@ -36,7 +36,11 @@ "remind_before", "column_break_4", "send_interview_feedback_reminder", - "feedback_reminder_notification_template" + "feedback_reminder_notification_template", + "employee_exit_section", + "exit_questionnaire_web_form", + "column_break_34", + "exit_questionnaire_notification_template" ], "fields": [ { @@ -226,13 +230,34 @@ "fieldname": "check_vacancies", "fieldtype": "Check", "label": "Check Vacancies On Job Offer Creation" + }, + { + "fieldname": "employee_exit_section", + "fieldtype": "Section Break", + "label": "Employee Exit Settings" + }, + { + "fieldname": "exit_questionnaire_web_form", + "fieldtype": "Link", + "label": "Exit Questionnaire Web Form", + "options": "Web Form" + }, + { + "fieldname": "exit_questionnaire_notification_template", + "fieldtype": "Link", + "label": "Exit Questionnaire Notification Template", + "options": "Email Template" + }, + { + "fieldname": "column_break_34", + "fieldtype": "Column Break" } ], "icon": "fa fa-cog", "idx": 1, "issingle": 1, "links": [], - "modified": "2021-10-01 23:46:11.098236", + "modified": "2021-12-05 14:48:10.884253", "modified_by": "Administrator", "module": "HR", "name": "HR Settings", From 235b707417316dc787fe4fb154f6db81896a9c13 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 5 Dec 2021 17:06:29 +0530 Subject: [PATCH 26/77] feat: add default Exit Questionnaire email template --- ...t_questionnaire_notification_template.html | 16 +++++++++++ erpnext/patches.txt | 1 + ...xit_questionnaire_notification_template.py | 27 +++++++++++++++++++ .../setup_wizard/operations/defaults_setup.py | 2 ++ .../operations/install_fixtures.py | 5 ++++ 5 files changed, 51 insertions(+) create mode 100644 erpnext/hr/doctype/exit_interview/exit_questionnaire_notification_template.html create mode 100644 erpnext/patches/v14_0/add_default_exit_questionnaire_notification_template.py diff --git a/erpnext/hr/doctype/exit_interview/exit_questionnaire_notification_template.html b/erpnext/hr/doctype/exit_interview/exit_questionnaire_notification_template.html new file mode 100644 index 0000000000..0317b1a102 --- /dev/null +++ b/erpnext/hr/doctype/exit_interview/exit_questionnaire_notification_template.html @@ -0,0 +1,16 @@ +

Exit Questionnaire

+
+ +

+ Dear {{ employee_name }}, +

+ + Thank you for the contribution you have made during your time at {{ company }}. We value your opinion and welcome the feedback on your experience working with us. + Request you to take out a few minutes to fill up this Exit Questionnaire. + + {% set web_form = frappe.db.get_value('HR Settings', 'HR Settings', 'exit_questionnaire_web_form') %} + {% set web_form_link = frappe.utils.get_url(uri=frappe.db.get_value('Web Form', web_form, 'route')) %} + +

+ {{ _('Submit Now') }} +

diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 897e70ce25..717965a569 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -312,3 +312,4 @@ erpnext.patches.v13_0.update_category_in_ltds_certificate erpnext.patches.v13_0.create_pan_field_for_india #2 erpnext.patches.v14_0.delete_hub_doctypes erpnext.patches.v13_0.create_ksa_vat_custom_fields +erpnext.patches.v14_0.add_default_exit_questionnaire_notification_template \ No newline at end of file diff --git a/erpnext/patches/v14_0/add_default_exit_questionnaire_notification_template.py b/erpnext/patches/v14_0/add_default_exit_questionnaire_notification_template.py new file mode 100644 index 0000000000..8b1752b2c7 --- /dev/null +++ b/erpnext/patches/v14_0/add_default_exit_questionnaire_notification_template.py @@ -0,0 +1,27 @@ +import os + +import frappe +from frappe import _ + + +def execute(): + frappe.reload_doc("email", "doctype", "email_template") + frappe.reload_doc("hr", "doctype", "hr_settings") + + template = frappe.db.exists("Email Template", _("Exit Questionnaire Notification")) + if not template: + base_path = frappe.get_app_path("erpnext", "hr", "doctype") + response = frappe.read_file(os.path.join(base_path, "exit_interview/exit_questionnaire_notification_template.html")) + + template = frappe.get_doc({ + "doctype": "Email Template", + "name": _("Exit Questionnaire Notification"), + "response": response, + "subject": _("Exit Questionnaire Notification"), + "owner": frappe.session.user, + }).insert(ignore_permissions=True) + template = template.name + + hr_settings = frappe.get_doc("HR Settings") + hr_settings.exit_questionnaire_notification_template = template + hr_settings.save() diff --git a/erpnext/setup/setup_wizard/operations/defaults_setup.py b/erpnext/setup/setup_wizard/operations/defaults_setup.py index e4b1fa26ae..ca1f57eb1d 100644 --- a/erpnext/setup/setup_wizard/operations/defaults_setup.py +++ b/erpnext/setup/setup_wizard/operations/defaults_setup.py @@ -68,6 +68,8 @@ def set_default_settings(args): hr_settings.send_interview_feedback_reminder = 1 hr_settings.feedback_reminder_notification_template = _("Interview Feedback Reminder") + + hr_settings.exit_questionnaire_notification_template = _("Exit Questionnaire Notification") hr_settings.save() def set_no_copy_fields_in_variant_settings(): diff --git a/erpnext/setup/setup_wizard/operations/install_fixtures.py b/erpnext/setup/setup_wizard/operations/install_fixtures.py index 503aeacd01..323a7940b8 100644 --- a/erpnext/setup/setup_wizard/operations/install_fixtures.py +++ b/erpnext/setup/setup_wizard/operations/install_fixtures.py @@ -278,6 +278,11 @@ def install(country=None): records += [{'doctype': 'Email Template', 'name': _('Interview Feedback Reminder'), 'response': response, 'subject': _('Interview Feedback Reminder'), 'owner': frappe.session.user}] + response = frappe.read_file(os.path.join(base_path, 'exit_interview/exit_questionnaire_notification_template.html')) + + records += [{'doctype': 'Email Template', 'name': _('Exit Questionnaire Notification'), 'response': response, + 'subject': _('Exit Questionnaire Notification'), 'owner': frappe.session.user}] + base_path = frappe.get_app_path("erpnext", "stock", "doctype") response = frappe.read_file(os.path.join(base_path, "delivery_trip/dispatch_notification_template.html")) From 1347187a30b1c485d5d602bd5aca78c3efa11563 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 5 Dec 2021 17:20:39 +0530 Subject: [PATCH 27/77] feat: track status and final decision (Retained/Exit Confirmed) --- .../exit_interview/exit_interview.json | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.json b/erpnext/hr/doctype/exit_interview/exit_interview.json index 0712b3d2a5..bed0e776f1 100644 --- a/erpnext/hr/doctype/exit_interview/exit_interview.json +++ b/erpnext/hr/doctype/exit_interview/exit_interview.json @@ -14,6 +14,7 @@ "email", "column_break_5", "company", + "status", "date", "employee_details_section", "department", @@ -29,7 +30,9 @@ "reference_document_name", "interview_summary_section", "interviewers", - "text_editor_12" + "text_editor_12", + "employee_status_section", + "employee_status" ], "fields": [ { @@ -72,7 +75,6 @@ { "fieldname": "company", "fieldtype": "Link", - "in_list_view": 1, "in_standard_filter": 1, "label": "Company", "options": "Company", @@ -84,6 +86,7 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Date", + "mandatory_depends_on": "eval:doc.status==='Scheduled';", "reqd": 1 }, { @@ -168,6 +171,7 @@ "default": "0", "fieldname": "questionnaire_email_sent", "fieldtype": "Check", + "in_standard_filter": 1, "label": "Questionnaire Email Sent", "read_only": 1 }, @@ -177,11 +181,33 @@ "label": "Email ID", "options": "Email", "read_only": 1 + }, + { + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "options": "Pending\nScheduled\nCompleted", + "reqd": 1 + }, + { + "fieldname": "employee_status_section", + "fieldtype": "Section Break" + }, + { + "fieldname": "employee_status", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Final Decision", + "mandatory_depends_on": "eval:doc.status==='Completed';", + "options": "\nEmployee Retained\nExit Confirmed" } ], "index_web_pages_for_search": 1, "links": [], - "modified": "2021-12-05 16:50:05.933394", + "modified": "2021-12-05 17:17:20.033950", "modified_by": "Administrator", "module": "HR", "name": "Exit Interview", From 09fdfed1633110db19ffb978263c57b143349195 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 5 Dec 2021 18:28:23 +0530 Subject: [PATCH 28/77] fix: make Exit Interview submittable --- .../doctype/exit_interview/exit_interview.json | 17 ++++++++++++++--- .../hr/doctype/exit_interview/exit_interview.py | 12 ++++++++++++ 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.json b/erpnext/hr/doctype/exit_interview/exit_interview.json index bed0e776f1..d4d4aaceea 100644 --- a/erpnext/hr/doctype/exit_interview/exit_interview.json +++ b/erpnext/hr/doctype/exit_interview/exit_interview.json @@ -32,7 +32,8 @@ "interviewers", "text_editor_12", "employee_status_section", - "employee_status" + "employee_status", + "amended_from" ], "fields": [ { @@ -188,7 +189,7 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Status", - "options": "Pending\nScheduled\nCompleted", + "options": "Pending\nScheduled\nCompleted\nCancelled", "reqd": 1 }, { @@ -203,11 +204,21 @@ "label": "Final Decision", "mandatory_depends_on": "eval:doc.status==='Completed';", "options": "\nEmployee Retained\nExit Confirmed" + }, + { + "fieldname": "amended_from", + "fieldtype": "Link", + "label": "Amended From", + "no_copy": 1, + "options": "Exit Interview", + "print_hide": 1, + "read_only": 1 } ], "index_web_pages_for_search": 1, + "is_submittable": 1, "links": [], - "modified": "2021-12-05 17:17:20.033950", + "modified": "2021-12-05 17:49:44.839277", "modified_by": "Administrator", "module": "HR", "name": "Exit Interview", diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.py b/erpnext/hr/doctype/exit_interview/exit_interview.py index a59146a898..878380b9ec 100644 --- a/erpnext/hr/doctype/exit_interview/exit_interview.py +++ b/erpnext/hr/doctype/exit_interview/exit_interview.py @@ -12,6 +12,7 @@ from erpnext.hr.doctype.employee.employee import get_employee_email class ExitInterview(Document): def validate(self): self.validate_relieving_date() + self.validate_duplicate_interview() self.set_employee_email() def validate_relieving_date(self): @@ -20,10 +21,21 @@ class ExitInterview(Document): get_link_to_form('Employee', self.employee)), title=_('Relieving Date Missing')) + def validate_duplicate_interview(self): + doc = frappe.db.exists('Exit Interview', {'employee': self.employee, 'name': ('!=', self.name)}) + if doc: + frappe.throw(_('Exit Interview {0} already scheduled for Employee: {1}').format( + get_link_to_form('Exit Interview', doc), frappe.bold(self.employee)), + title=_('Duplicate Document')) + def set_employee_email(self): employee = frappe.get_doc('Employee', self.employee) self.email = get_employee_email(employee) + def on_submit(self): + if self.status != 'Completed': + frappe.throw(_('Only Completed documents can be submitted')) + @frappe.whitelist() def send_exit_questionnaire(exit_interview): From e30187f2469bb82fe1ed98abaa4f126053ba9371 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 5 Dec 2021 19:32:33 +0530 Subject: [PATCH 29/77] feat: bulk questionnaire sending --- .../doctype/exit_interview/exit_interview.js | 32 ++----- .../exit_interview/exit_interview.json | 6 +- .../doctype/exit_interview/exit_interview.py | 90 ++++++++++++++----- .../exit_interview/exit_interview_list.js | 27 ++++++ 4 files changed, 108 insertions(+), 47 deletions(-) create mode 100644 erpnext/hr/doctype/exit_interview/exit_interview_list.js diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.js b/erpnext/hr/doctype/exit_interview/exit_interview.js index 31c961063f..849e8542d2 100644 --- a/erpnext/hr/doctype/exit_interview/exit_interview.js +++ b/erpnext/hr/doctype/exit_interview/exit_interview.js @@ -23,29 +23,15 @@ frappe.ui.form.on('Exit Interview', { }, send_exit_questionnaire: function(frm) { - frappe.db.get_value('HR Settings', 'HR Settings', - ['exit_questionnaire_web_form', 'exit_questionnaire_notification_template'], (r) => { - if (!r.exit_questionnaire_web_form || !r.exit_questionnaire_notification_template) { - frappe.throw({ - message: __('Please set {0} and {1} in {2}.', - ['Exit Questionnaire Web Form'.bold(), - 'Notification Template'.bold(), - 'HR Settings'] - ), - title: __('Settings Missing') - }); - } else { - frappe.call({ - method: 'erpnext.hr.doctype.exit_interview.exit_interview.send_exit_questionnaire', - args: { - 'exit_interview': frm.doc.name - }, - callback: function(r) { - if (!r.exc) { - frm.refresh_field('questionnaire_email_sent'); - } - } - }); + frappe.call({ + method: 'erpnext.hr.doctype.exit_interview.exit_interview.send_exit_questionnaire', + args: { + 'interviews': [frm.doc] + }, + callback: function(r) { + if (!r.exc) { + frm.refresh_field('questionnaire_email_sent'); + } } }); } diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.json b/erpnext/hr/doctype/exit_interview/exit_interview.json index d4d4aaceea..4b396402db 100644 --- a/erpnext/hr/doctype/exit_interview/exit_interview.json +++ b/erpnext/hr/doctype/exit_interview/exit_interview.json @@ -87,8 +87,7 @@ "in_list_view": 1, "in_standard_filter": 1, "label": "Date", - "mandatory_depends_on": "eval:doc.status==='Scheduled';", - "reqd": 1 + "mandatory_depends_on": "eval:doc.status==='Scheduled';" }, { "fieldname": "exit_questionnaire_section", @@ -174,6 +173,7 @@ "fieldtype": "Check", "in_standard_filter": 1, "label": "Questionnaire Email Sent", + "no_copy": 1, "read_only": 1 }, { @@ -218,7 +218,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-12-05 17:49:44.839277", + "modified": "2021-12-05 18:56:34.856854", "modified_by": "Administrator", "module": "HR", "name": "Exit Interview", diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.py b/erpnext/hr/doctype/exit_interview/exit_interview.py index 878380b9ec..b2ada4d83c 100644 --- a/erpnext/hr/doctype/exit_interview/exit_interview.py +++ b/erpnext/hr/doctype/exit_interview/exit_interview.py @@ -38,28 +38,76 @@ class ExitInterview(Document): @frappe.whitelist() -def send_exit_questionnaire(exit_interview): - exit_interview = frappe.get_doc('Exit Interview', exit_interview) - context = exit_interview.as_dict() +def send_exit_questionnaire(interviews): + interviews = get_interviews(interviews) + validate_questionnaire_settings() - employee = frappe.get_doc('Employee', exit_interview.employee) - context.update(employee.as_dict()) + email_success = [] + email_failure = [] - email = get_employee_email(employee) - template_name = frappe.db.get_single_value('HR Settings', 'exit_questionnaire_notification_template') - template = frappe.get_doc('Email Template', template_name) + for exit_interview in interviews: + interview = frappe.get_doc('Exit Interview', exit_interview.get('name')) + if interview.get('questionnaire_email_sent'): + continue - if email: - frappe.sendmail( - recipients=email, - subject=template.subject, - message=frappe.render_template(template.response, context), - reference_doctype=exit_interview.doctype, - reference_name=exit_interview.name + employee = frappe.get_doc('Employee', interview.employee) + email = get_employee_email(employee) + + context = interview.as_dict() + context.update(employee.as_dict()) + template_name = frappe.db.get_single_value('HR Settings', 'exit_questionnaire_notification_template') + template = frappe.get_doc('Email Template', template_name) + + if email: + frappe.sendmail( + recipients=email, + subject=template.subject, + message=frappe.render_template(template.response, context), + reference_doctype=interview.doctype, + reference_name=interview.name + ) + interview.db_set('questionnaire_email_sent', True) + interview.notify_update() + email_success.append(email) + else: + email_failure.append(get_link_to_form('Employee', employee.name)) + + show_email_summary(email_success, email_failure) + + +def get_interviews(interviews): + import json + + if isinstance(interviews, str): + interviews = json.loads(interviews) + + if not len(interviews): + frappe.throw(_('Atleast one interview has to be selected.')) + + return interviews + + +def validate_questionnaire_settings(): + settings = frappe.db.get_value('HR Settings', 'HR Settings', + ['exit_questionnaire_web_form', 'exit_questionnaire_notification_template'], as_dict=True) + + if not settings.exit_questionnaire_web_form or not settings.exit_questionnaire_notification_template: + frappe.throw( + message=_('Please set {0} and {1} in {2}.').format( + frappe.bold('Exit Questionnaire Web Form'), + frappe.bold('Notification Template'), + get_link_to_form('HR Settings', 'HR Settings')), + title=_('Settings Missing') ) - frappe.msgprint(_('Exit Questionnaire sent to {0}').format(email), - title='Success', indicator='green') - exit_interview.db_set('questionnaire_email_sent', True) - exit_interview.notify_update() - else: - frappe.msgprint(_('Email IDs for employee not found.')) \ No newline at end of file + + +def show_email_summary(email_success, email_failure): + message = '' + if email_success: + message += _('{0}: {1}').format( + frappe.bold('Sent Successfully'), ', '.join(email_success)) + if email_failure: + message += + '

' + _('{0} due to missing email information for employee(s): {1}').format( + frappe.bold('Sending Failed'), ', '.join(email_failure)) + + frappe.msgprint(message, title=_('Exit Questionnaire'), indicator='blue', is_minimizable=True, wide=True) \ No newline at end of file diff --git a/erpnext/hr/doctype/exit_interview/exit_interview_list.js b/erpnext/hr/doctype/exit_interview/exit_interview_list.js new file mode 100644 index 0000000000..93d7b213f2 --- /dev/null +++ b/erpnext/hr/doctype/exit_interview/exit_interview_list.js @@ -0,0 +1,27 @@ +frappe.listview_settings['Exit Interview'] = { + has_indicator_for_draft: 1, + get_indicator: function(doc) { + let status_color = { + 'Pending': 'orange', + 'Scheduled': 'yellow', + 'Completed': 'green', + 'Cancelled': 'red', + }; + return [__(doc.status), status_color[doc.status], 'status,=,'+doc.status]; + }, + + onload: function(listview) { + if (frappe.boot.user.can_write.includes('Exit Interview')) { + listview.page.add_action_item(__('Send Exit Questionnaires'), function() { + const interviews = listview.get_checked_items(); + frappe.call({ + method: 'erpnext.hr.doctype.exit_interview.exit_interview.send_exit_questionnaire', + freeze: true, + args: { + 'interviews': interviews + } + }); + }); + } + } +}; From d67536cc8d11ce7ec6e4fa0f46f633eba724ba33 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 5 Dec 2021 19:55:25 +0530 Subject: [PATCH 30/77] feat: update Exit Interview date in employee master on submission --- .../hr/doctype/employee/employee_dashboard.py | 6 +++++- .../doctype/exit_interview/exit_interview.py | 18 +++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/erpnext/hr/doctype/employee/employee_dashboard.py b/erpnext/hr/doctype/employee/employee_dashboard.py index a4c0af0a4b..a1247d9eb1 100644 --- a/erpnext/hr/doctype/employee/employee_dashboard.py +++ b/erpnext/hr/doctype/employee/employee_dashboard.py @@ -21,7 +21,11 @@ def get_data(): }, { 'label': _('Lifecycle'), - 'items': ['Employee Transfer', 'Employee Promotion', 'Employee Separation', 'Employee Grievance'] + 'items': ['Employee Transfer', 'Employee Promotion', 'Employee Grievance'] + }, + { + 'label': _('Exit'), + 'items': ['Employee Separation', 'Exit Interview', 'Full and Final Statement'] }, { 'label': _('Shift'), diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.py b/erpnext/hr/doctype/exit_interview/exit_interview.py index b2ada4d83c..59dd4631c7 100644 --- a/erpnext/hr/doctype/exit_interview/exit_interview.py +++ b/erpnext/hr/doctype/exit_interview/exit_interview.py @@ -22,7 +22,11 @@ class ExitInterview(Document): title=_('Relieving Date Missing')) def validate_duplicate_interview(self): - doc = frappe.db.exists('Exit Interview', {'employee': self.employee, 'name': ('!=', self.name)}) + doc = frappe.db.exists('Exit Interview', { + 'employee': self.employee, + 'name': ('!=', self.name), + 'docstatus': ('!=', 2) + }) if doc: frappe.throw(_('Exit Interview {0} already scheduled for Employee: {1}').format( get_link_to_form('Exit Interview', doc), frappe.bold(self.employee)), @@ -36,6 +40,18 @@ class ExitInterview(Document): if self.status != 'Completed': frappe.throw(_('Only Completed documents can be submitted')) + self.update_interview_date_in_employee() + + def on_cancel(self): + self.update_interview_date_in_employee() + self.db_set('status', 'Cancelled') + + def update_interview_date_in_employee(self): + if self.docstatus == 1: + frappe.db.set_value('Employee', self.employee, 'held_on', self.date) + elif self.docstatus == 2: + frappe.db.set_value('Employee', self.employee, 'held_on', None) + @frappe.whitelist() def send_exit_questionnaire(interviews): From 3437f568be7ceafa3dc00af163c185aeec865c77 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 5 Dec 2021 21:55:13 +0530 Subject: [PATCH 31/77] feat: Employee Exits report --- erpnext/hr/report/employee_exits/__init__.py | 0 .../report/employee_exits/employee_exits.js | 77 ++++++ .../report/employee_exits/employee_exits.json | 33 +++ .../report/employee_exits/employee_exits.py | 231 ++++++++++++++++++ 4 files changed, 341 insertions(+) create mode 100644 erpnext/hr/report/employee_exits/__init__.py create mode 100644 erpnext/hr/report/employee_exits/employee_exits.js create mode 100644 erpnext/hr/report/employee_exits/employee_exits.json create mode 100644 erpnext/hr/report/employee_exits/employee_exits.py diff --git a/erpnext/hr/report/employee_exits/__init__.py b/erpnext/hr/report/employee_exits/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/hr/report/employee_exits/employee_exits.js b/erpnext/hr/report/employee_exits/employee_exits.js new file mode 100644 index 0000000000..ac677d87e7 --- /dev/null +++ b/erpnext/hr/report/employee_exits/employee_exits.js @@ -0,0 +1,77 @@ +// Copyright (c) 2016, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt +/* eslint-disable */ + +frappe.query_reports["Employee Exits"] = { + filters: [ + { + "fieldname": "from_date", + "label": __("From Date"), + "fieldtype": "Date", + "default": frappe.datetime.add_months(frappe.datetime.nowdate(), -12) + }, + { + "fieldname": "to_date", + "label": __("To Date"), + "fieldtype": "Date", + "default": frappe.datetime.nowdate() + }, + { + "fieldname": "company", + "label": __("Company"), + "fieldtype": "Link", + "options": "Company" + }, + { + "fieldname": "department", + "label": __("Department"), + "fieldtype": "Link", + "options": "Department" + }, + { + "fieldname": "designation", + "label": __("Designation"), + "fieldtype": "Link", + "options": "Designation" + }, + { + "fieldname": "employee", + "label": __("Employee"), + "fieldtype": "Link", + "options": "Employee" + }, + { + "fieldname": "reports_to", + "label": __("Reports To"), + "fieldtype": "Link", + "options": "Employee" + }, + { + "fieldname": "interview_status", + "label": __("Interview Status"), + "fieldtype": "Select", + "options": ["", "Pending", "Scheduled", "Completed"] + }, + { + "fieldname": "final_decision", + "label": __("Final Decision"), + "fieldtype": "Select", + "options": ["", "Employee Retained", "Exit Confirmed"] + }, + { + "fieldname": "exit_interview_pending", + "label": __("Exit Interview Pending"), + "fieldtype": "Check" + }, + { + "fieldname": "questionnaire_pending", + "label": __("Exit Questionnaire Pending"), + "fieldtype": "Check" + }, + { + "fieldname": "fnf_pending", + "label": __("FnF Pending"), + "fieldtype": "Check" + } + ] +}; diff --git a/erpnext/hr/report/employee_exits/employee_exits.json b/erpnext/hr/report/employee_exits/employee_exits.json new file mode 100644 index 0000000000..4fe9a853c0 --- /dev/null +++ b/erpnext/hr/report/employee_exits/employee_exits.json @@ -0,0 +1,33 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2021-12-05 19:47:18.332319", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letter_head": "Test", + "modified": "2021-12-05 19:47:18.332319", + "modified_by": "Administrator", + "module": "HR", + "name": "Employee Exits", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Exit Interview", + "report_name": "Employee Exits", + "report_type": "Script Report", + "roles": [ + { + "role": "System Manager" + }, + { + "role": "HR Manager" + }, + { + "role": "HR User" + } + ] +} \ No newline at end of file diff --git a/erpnext/hr/report/employee_exits/employee_exits.py b/erpnext/hr/report/employee_exits/employee_exits.py new file mode 100644 index 0000000000..fd49543d08 --- /dev/null +++ b/erpnext/hr/report/employee_exits/employee_exits.py @@ -0,0 +1,231 @@ +# Copyright (c) 2013, Frappe Technologies Pvt. Ltd. and contributors +# License: MIT. See LICENSE + +import frappe +from frappe import _ +from frappe.utils import getdate + + +def execute(filters=None): + columns = get_columns() + data = get_data(filters) + chart = get_chart_data(data) + report_summary = get_report_summary(data) + + return columns, data, None, chart, report_summary + +def get_columns(): + return [ + { + 'label': _('Employee'), + 'fieldname': 'employee', + 'fieldtype': 'Link', + 'options': 'Employee', + 'width': 150 + }, + { + 'label': _('Employee Name'), + 'fieldname': 'employee_name', + 'fieldtype': 'Data', + 'width': 150 + }, + { + 'label': _('Date of Joining'), + 'fieldname': 'date_of_joining', + 'fieldtype': 'Date', + 'width': 100 + }, + { + 'label': _('Relieving Date'), + 'fieldname': 'relieving_date', + 'fieldtype': 'Date', + 'width': 100 + }, + { + 'label': _('Department'), + 'fieldname': 'department', + 'fieldtype': 'Link', + 'options': 'Department', + 'width': 120 + }, + { + 'label': _('Designation'), + 'fieldname': 'designation', + 'fieldtype': 'Link', + 'options': 'Designation', + 'width': 120 + }, + { + 'label': _('Reports To'), + 'fieldname': 'reports_to', + 'fieldtype': 'Link', + 'options': 'Employee', + 'width': 120 + }, + { + 'label': _('Exit Interview'), + 'fieldname': 'exit_interview', + 'fieldtype': 'Link', + 'options': 'Exit Interview', + 'width': 98 + }, + { + 'label': _('Interview Status'), + 'fieldname': 'interview_status', + 'fieldtype': 'Data', + 'width': 98 + }, + { + 'label': _('Final Decision'), + 'fieldname': 'employee_status', + 'fieldtype': 'Data', + 'width': 98 + }, + { + 'label': _('Full and Final Statement'), + 'fieldname': 'full_and_final_statement', + 'fieldtype': 'Link', + 'options': 'Full and Final Statement', + 'width': 150 + } + ] + +def get_data(filters): + data = [] + + employee = frappe.qb.DocType('Employee') + interview = frappe.qb.DocType('Exit Interview') + fnf = frappe.qb.DocType('Full and Final Statement') + + query = ( + frappe.qb.from_(employee) + .left_join(interview).on(interview.employee == employee.name) + .left_join(fnf).on(fnf.employee == employee.name) + .select( + employee.name.as_('employee'), employee.employee_name.as_('employee_name'), + employee.date_of_joining.as_('date_of_joining'), employee.relieving_date.as_('relieving_date'), + employee.department.as_('department'), employee.designation.as_('designation'), + employee.reports_to.as_('reports_to'), interview.name.as_('exit_interview'), + interview.status.as_('interview_status'), interview.employee_status.as_('employee_status'), + interview.reference_document_name.as_('questionnaire'), fnf.name.as_('full_and_final_statement') + ).distinct() + .where( + ((employee.relieving_date.isnotnull()) | (employee.relieving_date != '')) + & ((interview.name.isnull()) | ((interview.name.isnotnull()) & (interview.docstatus != 2))) + & ((fnf.name.isnull()) | ((fnf.name.isnotnull()) & (fnf.docstatus != 2))) + ) + ) + + query = get_conditions(filters, query, employee, interview, fnf) + result = query.run(as_dict=True) + + return result + + +def get_conditions(filters, query, employee, interview, fnf): + if filters.get('from_date') and filters.get('to_date'): + query = query.where(employee.relieving_date[getdate(filters.get('from_date')):getdate(filters.get('to_date'))]) + + elif filters.get('from_date'): + query = query.where(employee.relieving_date >= filters.get('from_date')) + + elif filters.get('to_date'): + query = query.where(employee.relieving_date <= filters.get('to_date')) + + if filters.get('company'): + query = query.where(employee.company == filters.get('company')) + + if filters.get('department'): + query = query.where(employee.department == filters.get('department')) + + if filters.get('designation'): + query = query.where(employee.designation == filters.get('designation')) + + if filters.get('employee'): + query = query.where(employee.name == filters.get('employee')) + + if filters.get('reports_to'): + query = query.where(employee.reports_to == filters.get('reports_to')) + + if filters.get('interview_status'): + query = query.where(interview.status == filters.get('interview_status')) + + if filters.get('final_decision'): + query = query.where(interview.employee_status == filters.get('final_decision')) + + if filters.get('exit_interview_pending'): + query = query.where((interview.name == '') | (interview.name.isnull())) + + if filters.get('questionnaire_pending'): + query = query.where((interview.reference_document_name == '') | (interview.reference_document_name.isnull())) + + if filters.get('fnf_pending'): + query = query.where((fnf.name == '') | (interview.name.isnull())) + + return query + + +def get_chart_data(data): + if not data: + return None + + retained = 0 + exit_confirmed = 0 + pending = 0 + + for entry in data: + if entry.employee_status == 'Employee Retained': + retained += 1 + elif entry.employee_status == 'Exit Confirmed': + exit_confirmed += 1 + else: + pending += 1 + + chart = { + 'data': { + 'labels': [_('Retained'), _('Exit Confirmed'), _('Decision Pending')], + 'datasets': [{'name': _('Employee Status'), 'values': [retained, exit_confirmed, pending]}] + }, + 'type': 'donut', + 'colors': ['green', 'red', 'blue'], + } + + return chart + + +def get_report_summary(data): + if not data: + return None + + total_resignations = len(data) + interviews_pending = len([entry.name for entry in data if not entry.exit_interview]) + fnf_pending = len([entry.name for entry in data if not entry.full_and_final_statement]) + questionnaires_pending = len([entry.name for entry in data if not entry.questionnaire]) + + return [ + { + 'value': total_resignations, + 'label': _('Total Resignations'), + 'indicator': 'Red' if total_resignations > 0 else 'Green', + 'datatype': 'Int', + }, + { + 'value': interviews_pending, + 'label': _('Pending Interviews'), + 'indicator': 'Blue' if interviews_pending > 0 else 'Green', + 'datatype': 'Int', + }, + { + 'value': fnf_pending, + 'label': _('Pending FnF'), + 'indicator': 'Blue' if fnf_pending > 0 else 'Green', + 'datatype': 'Int', + }, + { + 'value': questionnaires_pending, + 'label': _('Pending Questionnaires'), + 'indicator': 'Blue' if questionnaires_pending > 0 else 'Green', + 'datatype': 'Int' + }, + ] + From 1c09439d037b2694c309f497a0b3d611e8241798 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 5 Dec 2021 22:06:19 +0530 Subject: [PATCH 32/77] chore: update HR workspace --- erpnext/hr/workspace/hr/hr.json | 871 +++++++++++++++++++++++++++++--- 1 file changed, 789 insertions(+), 82 deletions(-) diff --git a/erpnext/hr/workspace/hr/hr.json b/erpnext/hr/workspace/hr/hr.json index 7408d63eee..85e641c856 100644 --- a/erpnext/hr/workspace/hr/hr.json +++ b/erpnext/hr/workspace/hr/hr.json @@ -5,7 +5,7 @@ "label": "Outgoing Salary" } ], - "content": "[{\"type\": \"onboarding\", \"data\": {\"onboarding_name\":\"Human Resource\", \"col\": 12}}, {\"type\": \"chart\", \"data\": {\"chart_name\": \"Outgoing Salary\", \"col\": 12}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Your Shortcuts\", \"level\": 4, \"col\": 12}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Employee\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Leave Application\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Attendance\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Job Applicant\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Monthly Attendance Sheet\", \"col\": 4}}, {\"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Dashboard\", \"col\": 4}}, {\"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"type\": \"header\", \"data\": {\"text\": \"Reports & Masters\", \"level\": 4, \"col\": 12}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Employee\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Employee Lifecycle\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Shift Management\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Leaves\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Attendance\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Expense Claims\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Settings\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Fleet Management\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Recruitment\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Loans\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Training\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Performance\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Key Reports\", \"col\": 4}}, {\"type\": \"card\", \"data\": {\"card_name\": \"Other Reports\", \"col\": 4}}]", + "content": "[{\"type\":\"onboarding\",\"data\":{\"onboarding_name\":\"Human Resource\",\"col\":12}},{\"type\":\"chart\",\"data\":{\"chart_name\":\"Outgoing Salary\",\"col\":12}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Your Shortcuts\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\",\"level\":4,\"col\":12}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Employee\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Leave Application\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Attendance\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Job Applicant\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Monthly Attendance Sheet\",\"col\":4}},{\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Dashboard\",\"col\":4}},{\"type\":\"spacer\",\"data\":{\"col\":12}},{\"type\":\"header\",\"data\":{\"text\":\"Reports & Masters\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\\n\\t\\t\\t\\n\\t\\t\",\"level\":4,\"col\":12}},{\"type\":\"card\",\"data\":{\"card_name\":\"Employee\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Employee Lifecycle\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Employee Exit\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Shift Management\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Leaves\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Attendance\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Expense Claims\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Settings\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Loans\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Recruitment\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Performance\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Fleet Management\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Training\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Key Reports\",\"col\":4}},{\"type\":\"card\",\"data\":{\"card_name\":\"Other Reports\",\"col\":4}}]", "creation": "2020-03-02 15:48:58.322521", "docstatus": 0, "doctype": "Workspace", @@ -15,14 +15,6 @@ "idx": 0, "label": "HR", "links": [ - { - "hidden": 0, - "is_query_report": 0, - "label": "Employee", - "link_count": 0, - "onboard": 0, - "type": "Card Break" - }, { "dependencies": "", "hidden": 0, @@ -111,14 +103,6 @@ "onboard": 0, "type": "Link" }, - { - "hidden": 0, - "is_query_report": 0, - "label": "Employee Lifecycle", - "link_count": 0, - "onboard": 0, - "type": "Card Break" - }, { "dependencies": "Job Applicant", "hidden": 0, @@ -227,14 +211,6 @@ "onboard": 0, "type": "Link" }, - { - "hidden": 0, - "is_query_report": 0, - "label": "Shift Management", - "link_count": 0, - "onboard": 0, - "type": "Card Break" - }, { "dependencies": "", "hidden": 0, @@ -268,14 +244,6 @@ "onboard": 0, "type": "Link" }, - { - "hidden": 0, - "is_query_report": 0, - "label": "Leaves", - "link_count": 0, - "onboard": 0, - "type": "Card Break" - }, { "dependencies": "", "hidden": 0, @@ -386,14 +354,6 @@ "onboard": 0, "type": "Link" }, - { - "hidden": 0, - "is_query_report": 0, - "label": "Attendance", - "link_count": 0, - "onboard": 0, - "type": "Card Break" - }, { "dependencies": "Employee", "hidden": 0, @@ -449,14 +409,6 @@ "onboard": 0, "type": "Link" }, - { - "hidden": 0, - "is_query_report": 0, - "label": "Expense Claims", - "link_count": 0, - "onboard": 0, - "type": "Card Break" - }, { "dependencies": "Employee", "hidden": 0, @@ -489,14 +441,6 @@ "onboard": 0, "type": "Link" }, - { - "hidden": 0, - "is_query_report": 0, - "label": "Settings", - "link_count": 0, - "onboard": 0, - "type": "Card Break" - }, { "dependencies": "", "hidden": 0, @@ -530,14 +474,6 @@ "onboard": 0, "type": "Link" }, - { - "hidden": 0, - "is_query_report": 0, - "label": "Fleet Management", - "link_count": 0, - "onboard": 0, - "type": "Card Break" - }, { "hidden": 0, "is_query_report": 0, @@ -581,14 +517,6 @@ "onboard": 0, "type": "Link" }, - { - "hidden": 0, - "is_query_report": 0, - "label": "Recruitment", - "link_count": 0, - "onboard": 0, - "type": "Card Break" - }, { "dependencies": "", "hidden": 0, @@ -808,14 +736,6 @@ "onboard": 0, "type": "Link" }, - { - "hidden": 0, - "is_query_report": 0, - "label": "Key Reports", - "link_count": 0, - "onboard": 0, - "type": "Card Break" - }, { "dependencies": "Attendance", "hidden": 0, @@ -933,9 +853,796 @@ "link_type": "Report", "onboard": 0, "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Employee Lifecycle", + "link_count": 7, + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Job Applicant", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Onboarding", + "link_count": 0, + "link_to": "Employee Onboarding", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Skill Map", + "link_count": 0, + "link_to": "Employee Skill Map", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Promotion", + "link_count": 0, + "link_to": "Employee Promotion", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Transfer", + "link_count": 0, + "link_to": "Employee Transfer", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Grievance Type", + "link_count": 0, + "link_to": "Grievance Type", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Employee Grievance", + "link_count": 0, + "link_to": "Employee Grievance", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Onboarding Template", + "link_count": 0, + "link_to": "Employee Onboarding Template", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Employee Exit", + "link_count": 4, + "onboard": 0, + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Employee Separation Template", + "link_count": 0, + "link_to": "Employee Separation Template", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Employee Separation", + "link_count": 0, + "link_to": "Employee Separation", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Full and Final Statement", + "link_count": 0, + "link_to": "Full and Final Statement", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Exit Interview", + "link_count": 0, + "link_to": "Exit Interview", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Employee", + "link_count": 8, + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Employee", + "link_count": 0, + "link_to": "Employee", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Employment Type", + "link_count": 0, + "link_to": "Employment Type", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Branch", + "link_count": 0, + "link_to": "Branch", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Department", + "link_count": 0, + "link_to": "Department", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Designation", + "link_count": 0, + "link_to": "Designation", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Grade", + "link_count": 0, + "link_to": "Employee Grade", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Group", + "link_count": 0, + "link_to": "Employee Group", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Health Insurance", + "link_count": 0, + "link_to": "Employee Health Insurance", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Key Reports", + "link_count": 7, + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Attendance", + "hidden": 0, + "is_query_report": 1, + "label": "Monthly Attendance Sheet", + "link_count": 0, + "link_to": "Monthly Attendance Sheet", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Staffing Plan", + "hidden": 0, + "is_query_report": 1, + "label": "Recruitment Analytics", + "link_count": 0, + "link_to": "Recruitment Analytics", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 1, + "label": "Employee Analytics", + "link_count": 0, + "link_to": "Employee Analytics", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 1, + "label": "Employee Leave Balance", + "link_count": 0, + "link_to": "Employee Leave Balance", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 1, + "label": "Employee Leave Balance Summary", + "link_count": 0, + "link_to": "Employee Leave Balance Summary", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee Advance", + "hidden": 0, + "is_query_report": 1, + "label": "Employee Advance Summary", + "link_count": 0, + "link_to": "Employee Advance Summary", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Employee Exits", + "link_count": 0, + "link_to": "Employee Exits", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Recruitment", + "link_count": 11, + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Job Opening", + "link_count": 0, + "link_to": "Job Opening", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Employee Referral", + "link_count": 0, + "link_to": "Employee Referral", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Job Applicant", + "link_count": 0, + "link_to": "Job Applicant", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Job Offer", + "link_count": 0, + "link_to": "Job Offer", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Staffing Plan", + "link_count": 0, + "link_to": "Staffing Plan", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Appointment Letter", + "link_count": 0, + "link_to": "Appointment Letter", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Appointment Letter Template", + "link_count": 0, + "link_to": "Appointment Letter Template", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Interview Type", + "link_count": 0, + "link_to": "Interview Type", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Interview Round", + "link_count": 0, + "link_to": "Interview Round", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Interview", + "link_count": 0, + "link_to": "Interview", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Interview Feedback", + "link_count": 0, + "link_to": "Interview Feedback", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Fleet Management", + "link_count": 4, + "onboard": 0, + "type": "Card Break" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Driver", + "link_count": 0, + "link_to": "Driver", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Vehicle", + "link_count": 0, + "link_to": "Vehicle", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Vehicle Log", + "link_count": 0, + "link_to": "Vehicle Log", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Vehicle", + "hidden": 0, + "is_query_report": 1, + "label": "Vehicle Expenses", + "link_count": 0, + "link_to": "Vehicle Expenses", + "link_type": "Report", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Settings", + "link_count": 3, + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "HR Settings", + "link_count": 0, + "link_to": "HR Settings", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Daily Work Summary Group", + "link_count": 0, + "link_to": "Daily Work Summary Group", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Team Updates", + "link_count": 0, + "link_to": "team-updates", + "link_type": "Page", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Expense Claims", + "link_count": 3, + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Expense Claim", + "link_count": 0, + "link_to": "Expense Claim", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Advance", + "link_count": 0, + "link_to": "Employee Advance", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Travel Request", + "link_count": 0, + "link_to": "Travel Request", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Attendance", + "link_count": 5, + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Attendance Tool", + "link_count": 0, + "link_to": "Employee Attendance Tool", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Attendance", + "link_count": 0, + "link_to": "Attendance", + "link_type": "DocType", + "onboard": 1, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Attendance Request", + "link_count": 0, + "link_to": "Attendance Request", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Upload Attendance", + "link_count": 0, + "link_to": "Upload Attendance", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Employee Checkin", + "link_count": 0, + "link_to": "Employee Checkin", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Leaves", + "link_count": 10, + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Holiday List", + "link_count": 0, + "link_to": "Holiday List", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Leave Type", + "link_count": 0, + "link_to": "Leave Type", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Leave Period", + "link_count": 0, + "link_to": "Leave Period", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Leave Type", + "hidden": 0, + "is_query_report": 0, + "label": "Leave Policy", + "link_count": 0, + "link_to": "Leave Policy", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Leave Policy", + "hidden": 0, + "is_query_report": 0, + "label": "Leave Policy Assignment", + "link_count": 0, + "link_to": "Leave Policy Assignment", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Leave Application", + "link_count": 0, + "link_to": "Leave Application", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Leave Allocation", + "link_count": 0, + "link_to": "Leave Allocation", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Leave Encashment", + "link_count": 0, + "link_to": "Leave Encashment", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Leave Block List", + "link_count": 0, + "link_to": "Leave Block List", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "Employee", + "hidden": 0, + "is_query_report": 0, + "label": "Compensatory Leave Request", + "link_count": 0, + "link_to": "Compensatory Leave Request", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "hidden": 0, + "is_query_report": 0, + "label": "Shift Management", + "link_count": 3, + "onboard": 0, + "type": "Card Break" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Shift Type", + "link_count": 0, + "link_to": "Shift Type", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Shift Request", + "link_count": 0, + "link_to": "Shift Request", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Shift Assignment", + "link_count": 0, + "link_to": "Shift Assignment", + "link_type": "DocType", + "onboard": 0, + "type": "Link" } ], - "modified": "2021-08-31 12:18:59.842919", + "modified": "2021-12-05 22:05:13.004462", "modified_by": "Administrator", "module": "HR", "name": "HR", From b69e0d2c6353c924591c79c320e17b71c32104b0 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Sun, 5 Dec 2021 22:28:22 +0530 Subject: [PATCH 33/77] feat: default Notification - a day before Exit Interview --- .../exit_interview_scheduled/__init__.py | 0 .../exit_interview_scheduled.json | 29 +++++++++++++++ .../exit_interview_scheduled.md | 37 +++++++++++++++++++ .../exit_interview_scheduled.py | 5 +++ 4 files changed, 71 insertions(+) create mode 100644 erpnext/hr/notification/exit_interview_scheduled/__init__.py create mode 100644 erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.json create mode 100644 erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.md create mode 100644 erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.py diff --git a/erpnext/hr/notification/exit_interview_scheduled/__init__.py b/erpnext/hr/notification/exit_interview_scheduled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.json b/erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.json new file mode 100644 index 0000000000..8323ef0694 --- /dev/null +++ b/erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.json @@ -0,0 +1,29 @@ +{ + "attach_print": 0, + "channel": "Email", + "condition": "doc.date and doc.email and doc.docstatus != 2 and doc.status == 'Scheduled'", + "creation": "2021-12-05 22:11:47.263933", + "date_changed": "date", + "days_in_advance": 1, + "docstatus": 0, + "doctype": "Notification", + "document_type": "Exit Interview", + "enabled": 1, + "event": "Days Before", + "idx": 0, + "is_standard": 1, + "message": "\n\t\n\t\n\t\t\n\t\t\n\t\t\n\t\n\t\n
\n\t\t\t
\n\t\t\t\t{{_(\"Exit Interview Scheduled:\")}} {{ doc.name }}\n\t\t\t
\n\t\t
\n\n\n\t\n\t\n\t\t\n\t\t\n\t\t\n\t\n\t\n
\n\t\t\t
\n\t\t\t\t
    \n\t\t\t\t\t
  • {{_(\"Employee\")}}: {{ doc.employee }} - {{ doc.employee_name }}
  • \n\t\t\t\t\t
  • {{_(\"Date\")}}: {{ doc.date }}
  • \n\t\t\t\t\t
  • {{_(\"Interviewers\")}}:
  • \n\t\t\t\t\t{% for entry in doc.interviewers %}\n\t\t\t\t\t\t
      \n\t\t\t\t\t\t\t
    • {{ entry.user }}
    • \n\t\t\t\t\t\t
    \n\t\t\t\t\t{% endfor %}\n\t\t\t\t\t
  • {{ _(\"Interview Document\") }}: {{ frappe.utils.get_link_to_form(doc.doctype, doc.name) }}
  • \n\t\t\t\t
\n\t\t\t
\n\t\t
\n", + "modified": "2021-12-05 22:26:57.096159", + "modified_by": "Administrator", + "module": "HR", + "name": "Exit Interview Scheduled", + "owner": "Administrator", + "recipients": [ + { + "receiver_by_document_field": "email" + } + ], + "send_system_notification": 0, + "send_to_all_assignees": 1, + "subject": "Exit Interview Scheduled: {{ doc.name }}" +} \ No newline at end of file diff --git a/erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.md b/erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.md new file mode 100644 index 0000000000..6d6db4014b --- /dev/null +++ b/erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.md @@ -0,0 +1,37 @@ + + + + + + + + +
+
+

{{_("Exit Interview Scheduled:")}} {{ doc.name }}

+
+
+ + + + + + + + + +
+
+
    +
  • {{_("Employee")}}: {{ doc.employee }} - {{ doc.employee_name }}
  • +
  • {{_("Date")}}: {{ frappe.utils.formatdate(doc.date) }}
  • +
  • {{_("Interviewers")}}:
  • + {% for entry in doc.interviewers %} +
      +
    • {{ entry.user }}
    • +
    + {% endfor %} +
  • {{ _("Interview Document") }}: {{ frappe.utils.get_link_to_form(doc.doctype, doc.name) }}
  • +
+
+
diff --git a/erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.py b/erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.py new file mode 100644 index 0000000000..e1ada61927 --- /dev/null +++ b/erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.py @@ -0,0 +1,5 @@ +import frappe + +def get_context(context): + # do your magic here + pass From 3230741cdeca72daa018a3eaa903e4c59a24856c Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Mon, 6 Dec 2021 10:08:51 +0530 Subject: [PATCH 34/77] fix: email summary --- erpnext/hr/doctype/exit_interview/exit_interview.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.py b/erpnext/hr/doctype/exit_interview/exit_interview.py index 59dd4631c7..ba75100a3b 100644 --- a/erpnext/hr/doctype/exit_interview/exit_interview.py +++ b/erpnext/hr/doctype/exit_interview/exit_interview.py @@ -123,7 +123,7 @@ def show_email_summary(email_success, email_failure): message += _('{0}: {1}').format( frappe.bold('Sent Successfully'), ', '.join(email_success)) if email_failure: - message += + '

' + _('{0} due to missing email information for employee(s): {1}').format( + message += '

' + _('{0} due to missing email information for employee(s): {1}').format( frappe.bold('Sending Failed'), ', '.join(email_failure)) frappe.msgprint(message, title=_('Exit Questionnaire'), indicator='blue', is_minimizable=True, wide=True) \ No newline at end of file From f9408d170a8f7e863144003e5966f7b5a3219d3d Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 6 Dec 2021 12:38:25 +0530 Subject: [PATCH 35/77] patch: split 'ongoing' sla status --- erpnext/patches.txt | 1 + .../rename_ongoing_status_in_sla_documents.py | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 erpnext/patches/v14_0/rename_ongoing_status_in_sla_documents.py diff --git a/erpnext/patches.txt b/erpnext/patches.txt index 897e70ce25..d85f2339ae 100644 --- a/erpnext/patches.txt +++ b/erpnext/patches.txt @@ -312,3 +312,4 @@ erpnext.patches.v13_0.update_category_in_ltds_certificate erpnext.patches.v13_0.create_pan_field_for_india #2 erpnext.patches.v14_0.delete_hub_doctypes erpnext.patches.v13_0.create_ksa_vat_custom_fields +erpnext.patches.v14_0.rename_ongoing_status_in_sla_documents \ No newline at end of file diff --git a/erpnext/patches/v14_0/rename_ongoing_status_in_sla_documents.py b/erpnext/patches/v14_0/rename_ongoing_status_in_sla_documents.py new file mode 100644 index 0000000000..dddf4a3778 --- /dev/null +++ b/erpnext/patches/v14_0/rename_ongoing_status_in_sla_documents.py @@ -0,0 +1,27 @@ +import frappe + + +def execute(): + active_sla_documents = [sla.document_type for sla in frappe.get_all("Service Level Agreement", fields=["document_type"])] + + for doctype in active_sla_documents: + doctype = frappe.qb.DocType(doctype) + try: + query = ( + frappe.qb + .update(doctype) + .set(doctype.agreement_status, 'First Response Due') + .where( + (doctype.first_responded_on.isnull()) | (doctype.first_responded_on == '') + ) + ) + query.run() + query = ( + frappe.qb + .update(doctype) + .set(doctype.agreement_status, 'Resolution Due') + .where(doctype.agreement_status == 'Ongoing') + ) + query.run() + except Exception as e: + frappe.log_error('Failed to Patch SLA Status') \ No newline at end of file From c1d8877a838775ed04fb210ae629c34371ffe17b Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 6 Dec 2021 14:34:59 +0530 Subject: [PATCH 36/77] refactor: handle special cases on communication creation --- erpnext/hooks.py | 2 +- .../service_level_agreement.py | 34 +++++++++++++++---- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/erpnext/hooks.py b/erpnext/hooks.py index 546166fe19..09037f0f6c 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -234,7 +234,7 @@ doc_events = { }, "Communication": { "on_update": [ - "erpnext.support.doctype.service_level_agreement.service_level_agreement.update_hold_time", + "erpnext.support.doctype.service_level_agreement.service_level_agreement.on_communication_update", "erpnext.support.doctype.issue.issue.set_first_response_time" ] }, diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py index 662e42cc9f..9ae2d64d6f 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py @@ -376,14 +376,14 @@ def process_sla(doc, sla): doc.service_level_agreement = sla.name doc.priority = doc.get("priority") or sla.default_priority - prev_status = frappe.db.get_value(doc.doctype, doc.name, 'status') - handle_status_change(doc, prev_status, sla.apply_sla_for_resolution) + handle_status_change(doc, sla.apply_sla_for_resolution) update_response_and_resolution_metrics(doc, sla.apply_sla_for_resolution) update_agreement_status(doc, sla.apply_sla_for_resolution) -def handle_status_change(doc, prev_status, apply_sla_for_resolution): +def handle_status_change(doc, apply_sla_for_resolution): now_time = frappe.flags.current_time or now_datetime(doc.get("owner")) + prev_status = frappe.db.get_value(doc.doctype, doc.name, 'status') hold_statuses = get_hold_statuses(doc.service_level_agreement) fulfillment_statuses = get_fulfillment_statuses(doc.service_level_agreement) @@ -407,7 +407,7 @@ def handle_status_change(doc, prev_status, apply_sla_for_resolution): doc.total_hold_time = (doc.total_hold_time or 0) + current_hold_hours doc.on_hold_since = None - if is_open_status(prev_status) and not is_open_status(doc.status): + if ((is_open_status(prev_status) and not is_open_status(doc.status)) or doc.flags.on_first_reply): # status changed from Open to something else if doc.meta.has_field("first_responded_on") and not doc.get('first_responded_on'): doc.first_responded_on = now_time @@ -625,8 +625,8 @@ def reset_resolution_metrics(doc): # called via hooks on communication update -def update_hold_time(doc, status): - if doc.communication_type == "Comment" or doc.sent_or_received != "Received": +def on_communication_update(doc, status): + if doc.communication_type == "Comment": return parent = get_parent_doc(doc) @@ -638,7 +638,27 @@ def update_hold_time(doc, status): for_resolution = frappe.db.get_value('Service Level Agreement', parent.service_level_agreement, 'apply_sla_for_resolution') - handle_status_change(parent, 'Replied', for_resolution) + if ( + doc.sent_or_received == "Received" # a reply is received + and parent.get('status') == 'Open' # issue status is set as open from communication.py + and parent._doc_before_save + and parent.get('status') != parent._doc_before_save.get('status') # status changed + ): + # undo the status change in db + # since prev status is fetched from db + frappe.db.set_value(parent.doctype, parent.name, 'status', parent._doc_before_save.get('status')) + + elif ( + doc.sent_or_received == "Sent" # a reply is sent + and parent.get('first_responded_on') # first_responded_on is set from communication.py + and parent._doc_before_save + and not parent._doc_before_save.get('first_responded_on') # first_responded_on was not set + ): + # reset first_responded_on since it will be handled/set later on + parent.first_responded_on = None + parent.flags.on_first_reply = True + + handle_status_change(parent, for_resolution) update_response_and_resolution_metrics(parent, for_resolution) update_agreement_status(parent, for_resolution) From 812572d250e5dd18ebdd9e043826ba7616e504fe Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 6 Dec 2021 14:35:35 +0530 Subject: [PATCH 37/77] feat: record assignment on first response failure --- erpnext/support/doctype/issue/test_issue.py | 38 +++++++++++++++++++ .../service_level_agreement.py | 22 +++++++++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/erpnext/support/doctype/issue/test_issue.py b/erpnext/support/doctype/issue/test_issue.py index d566f33f24..477ac7260c 100644 --- a/erpnext/support/doctype/issue/test_issue.py +++ b/erpnext/support/doctype/issue/test_issue.py @@ -211,6 +211,43 @@ class TestIssue(TestSetUp): self.assertEquals(issue.agreement_status, 'Fulfilled') self.assertEquals(issue.resolution_date, frappe.flags.current_time) + def test_recording_of_assignment_on_first_reponse_failure(self): + from frappe.desk.form.assign_to import add as add_assignment + + frappe.flags.current_time = get_datetime("2021-11-01 19:00") + + issue = make_issue(frappe.flags.current_time, index=1) + create_communication(issue.name, "test@example.com", "Received", frappe.flags.current_time) + add_assignment({ + 'doctype': issue.doctype, + 'name': issue.name, + 'assign_to': ['test@admin.com'] + }) + issue.reload() + + # send a reply failing response SLA + frappe.flags.current_time = get_datetime("2021-11-02 15:00") + create_communication(issue.name, "test@admin.com", "Sent", frappe.flags.current_time) + + # assert if a new timeline item has been added + # to record the assignment + comment = frappe.get_last_doc('Comment') + self.assertTrue('First Response SLA Failed' in comment.content) + + def test_agreement_status_on_response(self): + frappe.flags.current_time = get_datetime("2021-11-01 19:00") + + issue = make_issue(frappe.flags.current_time, index=1) + create_communication(issue.name, "test@example.com", "Received", frappe.flags.current_time) + self.assertTrue(issue.status == 'Open') + + # send a reply within response SLA + frappe.flags.current_time = get_datetime("2021-11-02 11:00") + create_communication(issue.name, "test@admin.com", "Sent", frappe.flags.current_time) + + issue.reload() + self.assertEquals(issue.first_responded_on, frappe.flags.current_time) + self.assertEquals(issue.agreement_status, 'Resolution Due') class TestFirstResponseTime(TestSetUp): # working hours used in all cases: Mon-Fri, 10am to 6pm @@ -425,6 +462,7 @@ class TestFirstResponseTime(TestSetUp): def create_issue_and_communication(issue_creation, first_responded_on): issue = make_issue(issue_creation, index=1) sender = create_user("test@admin.com") + frappe.flags.current_time = first_responded_on create_communication(issue.name, sender.email, "Sent", first_responded_on) issue.reload() diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py index 9ae2d64d6f..19aa5783f7 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py @@ -397,6 +397,12 @@ def handle_status_change(doc, apply_sla_for_resolution): def is_open_status(status): return status not in hold_statuses and status not in fulfillment_statuses + def set_first_response(): + if doc.meta.has_field("first_responded_on") and not doc.get('first_responded_on'): + doc.first_responded_on = now_time + if get_datetime(doc.get('first_responded_on')) > get_datetime(doc.get('response_by')): + record_assigned_users_on_failure(doc) + def calculate_hold_hours(): # In case issue was closed and after few days it has been opened # The hold time should be calculated from resolution_date @@ -408,9 +414,7 @@ def handle_status_change(doc, apply_sla_for_resolution): doc.on_hold_since = None if ((is_open_status(prev_status) and not is_open_status(doc.status)) or doc.flags.on_first_reply): - # status changed from Open to something else - if doc.meta.has_field("first_responded_on") and not doc.get('first_responded_on'): - doc.first_responded_on = now_time + set_first_response() # Open to Replied if is_open_status(prev_status) and is_hold_status(doc.status): @@ -688,6 +692,18 @@ def set_resolution_by(doc, start_date_time, priority): doc.resolution_by = add_to_date(doc.resolution_by, seconds=round(doc.get('total_hold_time'))) +def record_assigned_users_on_failure(doc): + assigned_users = doc.get_assigned_users() + if assigned_users: + from frappe.utils import get_fullname + assigned_users = ', '.join((get_fullname(user) for user in assigned_users)) + message = _(f'First Response SLA Failed by {assigned_users}') + doc.add_comment( + comment_type='Assigned', + text=message + ) + + def get_service_level_agreement_fields(): return [ { From 3dabac15edf4baedfc3da288400e16933d497e70 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 6 Dec 2021 15:05:20 +0530 Subject: [PATCH 38/77] fix: Ageing in AR/AP report for advances --- .../report/accounts_receivable/accounts_receivable.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py index 353f9087f1..a990f23cd6 100755 --- a/erpnext/accounts/report/accounts_receivable/accounts_receivable.py +++ b/erpnext/accounts/report/accounts_receivable/accounts_receivable.py @@ -545,7 +545,9 @@ class ReceivablePayableReport(object): def set_ageing(self, row): if self.filters.ageing_based_on == "Due Date": - entry_date = row.due_date + # use posting date as a fallback for advances posted via journal and payment entry + # when ageing viewed by due date + entry_date = row.due_date or row.posting_date elif self.filters.ageing_based_on == "Supplier Invoice Date": entry_date = row.bill_date else: From 32c81818f619c25878ab5620b3f5e505ef43c1e7 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 6 Dec 2021 15:38:01 +0530 Subject: [PATCH 39/77] fix: transalations --- .../doctype/service_level_agreement/service_level_agreement.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py index 19aa5783f7..7527adf2bf 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py @@ -697,7 +697,7 @@ def record_assigned_users_on_failure(doc): if assigned_users: from frappe.utils import get_fullname assigned_users = ', '.join((get_fullname(user) for user in assigned_users)) - message = _(f'First Response SLA Failed by {assigned_users}') + message = _('First Response SLA Failed by {}').format(assigned_users) doc.add_comment( comment_type='Assigned', text=message From 10b87e081cd0523fe63f7c6254d683ea4fb48439 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 6 Dec 2021 15:38:19 +0530 Subject: [PATCH 40/77] fix: sider issues --- .../rename_ongoing_status_in_sla_documents.py | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/erpnext/patches/v14_0/rename_ongoing_status_in_sla_documents.py b/erpnext/patches/v14_0/rename_ongoing_status_in_sla_documents.py index dddf4a3778..b5296fbdb0 100644 --- a/erpnext/patches/v14_0/rename_ongoing_status_in_sla_documents.py +++ b/erpnext/patches/v14_0/rename_ongoing_status_in_sla_documents.py @@ -7,21 +7,21 @@ def execute(): for doctype in active_sla_documents: doctype = frappe.qb.DocType(doctype) try: - query = ( - frappe.qb - .update(doctype) - .set(doctype.agreement_status, 'First Response Due') - .where( - (doctype.first_responded_on.isnull()) | (doctype.first_responded_on == '') - ) - ) - query.run() - query = ( - frappe.qb - .update(doctype) - .set(doctype.agreement_status, 'Resolution Due') - .where(doctype.agreement_status == 'Ongoing') - ) - query.run() - except Exception as e: + frappe.qb.update( + doctype + ).set( + doctype.agreement_status, 'First Response Due' + ).where( + (doctype.first_responded_on.isnull()) | (doctype.first_responded_on == '') + ).run() + + frappe.qb.update( + doctype + ).set( + doctype.agreement_status, 'Resolution Due' + ).where( + doctype.agreement_status == 'Ongoing' + ).run() + + except Exception: frappe.log_error('Failed to Patch SLA Status') \ No newline at end of file From 1a76b3801ac1e933087b2cf14a1a3e74c6be8383 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 6 Dec 2021 16:16:13 +0530 Subject: [PATCH 41/77] fix: test --- erpnext/support/doctype/issue/test_issue.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/erpnext/support/doctype/issue/test_issue.py b/erpnext/support/doctype/issue/test_issue.py index 477ac7260c..14cec46ad4 100644 --- a/erpnext/support/doctype/issue/test_issue.py +++ b/erpnext/support/doctype/issue/test_issue.py @@ -1,10 +1,10 @@ # Copyright (c) 2015, Frappe Technologies Pvt. Ltd. and Contributors and Contributors # See license.txt -import datetime import unittest import frappe +from frappe import _ from frappe.core.doctype.user_permission.test_user_permission import create_user from frappe.utils import flt, get_datetime @@ -231,8 +231,13 @@ class TestIssue(TestSetUp): # assert if a new timeline item has been added # to record the assignment - comment = frappe.get_last_doc('Comment') - self.assertTrue('First Response SLA Failed' in comment.content) + comment = frappe.db.exists('Comment', { + 'reference_doctype': 'Issue', + 'reference_name': issue.name, + 'comment_type': 'Assigned', + 'content': _('First Response SLA Failed by {}').format('test') + }) + self.assertTrue(comment) def test_agreement_status_on_response(self): frappe.flags.current_time = get_datetime("2021-11-01 19:00") From 476e81a6312f9d38ae8b525b52f47317cbceefe5 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 6 Dec 2021 18:55:30 +0530 Subject: [PATCH 42/77] fix: time to respond & resolve indicators --- .../rename_ongoing_status_in_sla_documents.py | 4 ++-- erpnext/public/js/utils.js | 15 ++++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/erpnext/patches/v14_0/rename_ongoing_status_in_sla_documents.py b/erpnext/patches/v14_0/rename_ongoing_status_in_sla_documents.py index b5296fbdb0..1cc5f38f42 100644 --- a/erpnext/patches/v14_0/rename_ongoing_status_in_sla_documents.py +++ b/erpnext/patches/v14_0/rename_ongoing_status_in_sla_documents.py @@ -12,7 +12,7 @@ def execute(): ).set( doctype.agreement_status, 'First Response Due' ).where( - (doctype.first_responded_on.isnull()) | (doctype.first_responded_on == '') + doctype.first_responded_on.isnull() ).run() frappe.qb.update( @@ -24,4 +24,4 @@ def execute(): ).run() except Exception: - frappe.log_error('Failed to Patch SLA Status') \ No newline at end of file + frappe.log_error(title='Failed to Patch SLA Status') \ No newline at end of file diff --git a/erpnext/public/js/utils.js b/erpnext/public/js/utils.js index f0facdd3a1..2d8b1be93f 100755 --- a/erpnext/public/js/utils.js +++ b/erpnext/public/js/utils.js @@ -831,7 +831,7 @@ $(document).on('app_ready', function() { refresh: function(frm) { if (frm.doc.status !== 'Closed' && frm.doc.service_level_agreement - && frm.doc.agreement_status === 'Ongoing') { + && ['First Response Due', 'Resolution Due'].includes(frm.doc.agreement_status)) { frappe.call({ 'method': 'frappe.client.get', args: { @@ -884,8 +884,8 @@ $(document).on('app_ready', function() { function set_time_to_resolve_and_response(frm, apply_sla_for_resolution) { frm.dashboard.clear_headline(); - let time_to_respond = get_status(frm.doc.response_by_variance); - if (!frm.doc.first_responded_on && frm.doc.agreement_status === 'Ongoing') { + let time_to_respond = get_status(frm.doc.response_by); + if (!frm.doc.first_responded_on) { time_to_respond = get_time_left(frm.doc.response_by, frm.doc.agreement_status); } @@ -899,8 +899,8 @@ function set_time_to_resolve_and_response(frm, apply_sla_for_resolution) { if (apply_sla_for_resolution) { - let time_to_resolve = get_status(frm.doc.resolution_by_variance); - if (!frm.doc.resolution_date && frm.doc.agreement_status === 'Ongoing') { + let time_to_resolve = get_status(frm.doc.resolution_by); + if (!frm.doc.resolution_date) { time_to_resolve = get_time_left(frm.doc.resolution_by, frm.doc.agreement_status); } @@ -924,8 +924,9 @@ function get_time_left(timestamp, agreement_status) { return {'diff_display': diff_display, 'indicator': indicator}; } -function get_status(variance) { - if (variance > 0) { +function get_status(timestamp) { + const time_left = moment(timestamp).diff(moment()); + if (time_left >= 0) { return {'diff_display': 'Fulfilled', 'indicator': 'green'}; } else { return {'diff_display': 'Failed', 'indicator': 'red'}; From 91aa78707c471f688acb1147956f5aac3b84af99 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 6 Dec 2021 19:13:31 +0530 Subject: [PATCH 43/77] fix: remove missed 'ongoing' references --- erpnext/support/doctype/issue/issue.json | 2 +- .../doctype/service_level_agreement/service_level_agreement.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/erpnext/support/doctype/issue/issue.json b/erpnext/support/doctype/issue/issue.json index 38b5395adf..30aa7319df 100644 --- a/erpnext/support/doctype/issue/issue.json +++ b/erpnext/support/doctype/issue/issue.json @@ -372,7 +372,7 @@ "read_only": 1 }, { - "default": "Ongoing", + "default": "First Response Due", "depends_on": "eval: doc.service_level_agreement", "fieldname": "agreement_status", "fieldtype": "Select", diff --git a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py index 7527adf2bf..50f31fde2d 100644 --- a/erpnext/support/doctype/service_level_agreement/service_level_agreement.py +++ b/erpnext/support/doctype/service_level_agreement/service_level_agreement.py @@ -625,7 +625,7 @@ def reset_resolution_metrics(doc): doc.user_resolution_time = None if doc.meta.has_field("agreement_status"): - doc.agreement_status = "Ongoing" + doc.agreement_status = "First Response Due" # called via hooks on communication update From 3446f7545ff8178999a93fd2042c188905de1db2 Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 6 Dec 2021 19:17:38 +0530 Subject: [PATCH 44/77] fix: remove 'ongoing' status from issue summary report --- erpnext/support/report/issue_summary/issue_summary.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/erpnext/support/report/issue_summary/issue_summary.py b/erpnext/support/report/issue_summary/issue_summary.py index 39a5c407cd..67fe345d5f 100644 --- a/erpnext/support/report/issue_summary/issue_summary.py +++ b/erpnext/support/report/issue_summary/issue_summary.py @@ -82,7 +82,8 @@ class IssueSummary(object): self.sla_status_map = { 'SLA Failed': 'failed', 'SLA Fulfilled': 'fulfilled', - 'SLA Ongoing': 'ongoing' + 'First Response Due': 'first_response_due', + 'Resolution Due': 'resolution_due' } for label, fieldname in self.sla_status_map.items(): From 2f7d8ac29e58898e7fe7bd05b460abdcc1ea7a5a Mon Sep 17 00:00:00 2001 From: Saqib Ansari Date: Mon, 6 Dec 2021 19:19:34 +0530 Subject: [PATCH 45/77] fix: indentation --- erpnext/support/doctype/issue/issue.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/erpnext/support/doctype/issue/issue.json b/erpnext/support/doctype/issue/issue.json index 30aa7319df..3ff7d02f1a 100644 --- a/erpnext/support/doctype/issue/issue.json +++ b/erpnext/support/doctype/issue/issue.json @@ -347,16 +347,16 @@ "read_only": 1 }, { - "fieldname": "resolution_time", - "fieldtype": "Duration", - "label": "Resolution Time", - "read_only": 1 + "fieldname": "resolution_time", + "fieldtype": "Duration", + "label": "Resolution Time", + "read_only": 1 }, { - "fieldname": "user_resolution_time", - "fieldtype": "Duration", - "label": "User Resolution Time", - "read_only": 1 + "fieldname": "user_resolution_time", + "fieldtype": "Duration", + "label": "User Resolution Time", + "read_only": 1 }, { "fieldname": "on_hold_since", From d106d59c3f750a2545cb0c82f86c75fd88ab8276 Mon Sep 17 00:00:00 2001 From: Deepesh Garg Date: Mon, 6 Dec 2021 20:36:48 +0530 Subject: [PATCH 46/77] fix: TDS Monthly payable report --- .../report/tds_payable_monthly/tds_payable_monthly.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py index a3a45d1e79..caee1a10bb 100644 --- a/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py +++ b/erpnext/accounts/report/tds_payable_monthly/tds_payable_monthly.py @@ -36,12 +36,16 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map): posting_date = entry.posting_date voucher_type = entry.voucher_type + if not tax_withholding_category: + tax_withholding_category = supplier_map.get(supplier, {}).get('tax_withholding_category') + rate = tax_rate_map.get(tax_withholding_category) + if entry.account in tds_accounts: tds_deducted += (entry.credit - entry.debit) total_amount_credited += (entry.credit - entry.debit) - if rate and tds_deducted: + if tds_deducted: row = { 'pan' if frappe.db.has_column('Supplier', 'pan') else 'tax_id': supplier_map.get(supplier, {}).get('pan'), 'supplier': supplier_map.get(supplier, {}).get('name') @@ -67,7 +71,7 @@ def get_result(filters, tds_docs, tds_accounts, tax_category_map): def get_supplier_pan_map(): supplier_map = frappe._dict() - suppliers = frappe.db.get_all('Supplier', fields=['name', 'pan', 'supplier_type', 'supplier_name']) + suppliers = frappe.db.get_all('Supplier', fields=['name', 'pan', 'supplier_type', 'supplier_name', 'tax_withholding_category']) for d in suppliers: supplier_map[d.name] = d From 0a937dc0509e26d7f88e205902212c3b8987e202 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 7 Dec 2021 13:04:23 +0530 Subject: [PATCH 47/77] fix: show Exit Questionnaire button only to the users with write access - fix linter issues --- erpnext/hr/doctype/exit_interview/exit_interview.js | 2 +- erpnext/hr/doctype/exit_interview/exit_interview.py | 4 ++-- erpnext/hr/doctype/exit_interview/test_exit_interview.py | 1 + .../exit_interview_scheduled/exit_interview_scheduled.py | 3 ++- erpnext/hr/report/employee_exits/employee_exits.py | 2 -- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.js b/erpnext/hr/doctype/exit_interview/exit_interview.js index 849e8542d2..502af423a2 100644 --- a/erpnext/hr/doctype/exit_interview/exit_interview.js +++ b/erpnext/hr/doctype/exit_interview/exit_interview.js @@ -3,7 +3,7 @@ frappe.ui.form.on('Exit Interview', { refresh: function(frm) { - if (!frm.doc.__islocal && !frm.doc.questionnaire_email_sent) { + if (!frm.doc.__islocal && !frm.doc.questionnaire_email_sent && frappe.boot.user.can_write.includes('Exit Interview')) { frm.add_custom_button(__('Send Exit Questionnaire'), function () { frm.trigger('send_exit_questionnaire'); }); diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.py b/erpnext/hr/doctype/exit_interview/exit_interview.py index ba75100a3b..e72c47e8a7 100644 --- a/erpnext/hr/doctype/exit_interview/exit_interview.py +++ b/erpnext/hr/doctype/exit_interview/exit_interview.py @@ -28,9 +28,9 @@ class ExitInterview(Document): 'docstatus': ('!=', 2) }) if doc: - frappe.throw(_('Exit Interview {0} already scheduled for Employee: {1}').format( + frappe.throw(_('Exit Interview {0} already exists for Employee: {1}').format( get_link_to_form('Exit Interview', doc), frappe.bold(self.employee)), - title=_('Duplicate Document')) + frappe.DuplicateEntryError) def set_employee_email(self): employee = frappe.get_doc('Employee', self.employee) diff --git a/erpnext/hr/doctype/exit_interview/test_exit_interview.py b/erpnext/hr/doctype/exit_interview/test_exit_interview.py index daf3d66290..3a6316c200 100644 --- a/erpnext/hr/doctype/exit_interview/test_exit_interview.py +++ b/erpnext/hr/doctype/exit_interview/test_exit_interview.py @@ -4,5 +4,6 @@ # import frappe import unittest + class TestExitInterview(unittest.TestCase): pass diff --git a/erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.py b/erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.py index e1ada61927..5f697c9613 100644 --- a/erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.py +++ b/erpnext/hr/notification/exit_interview_scheduled/exit_interview_scheduled.py @@ -1,4 +1,5 @@ -import frappe +# import frappe + def get_context(context): # do your magic here diff --git a/erpnext/hr/report/employee_exits/employee_exits.py b/erpnext/hr/report/employee_exits/employee_exits.py index fd49543d08..93252295f3 100644 --- a/erpnext/hr/report/employee_exits/employee_exits.py +++ b/erpnext/hr/report/employee_exits/employee_exits.py @@ -91,8 +91,6 @@ def get_columns(): ] def get_data(filters): - data = [] - employee = frappe.qb.DocType('Employee') interview = frappe.qb.DocType('Exit Interview') fnf = frappe.qb.DocType('Full and Final Statement') From 8db21e065c0fceed7a63459ddbc8ee970ecb393b Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 7 Dec 2021 14:55:17 +0530 Subject: [PATCH 48/77] test: Exit Interview --- .../exit_interview/exit_interview.json | 14 ++-- .../doctype/exit_interview/exit_interview.py | 6 +- .../exit_interview/test_exit_interview.py | 84 ++++++++++++++++++- 3 files changed, 93 insertions(+), 11 deletions(-) diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.json b/erpnext/hr/doctype/exit_interview/exit_interview.json index 4b396402db..86720a105b 100644 --- a/erpnext/hr/doctype/exit_interview/exit_interview.json +++ b/erpnext/hr/doctype/exit_interview/exit_interview.json @@ -30,7 +30,7 @@ "reference_document_name", "interview_summary_section", "interviewers", - "text_editor_12", + "interview_summary", "employee_status_section", "employee_status", "amended_from" @@ -112,11 +112,6 @@ "fieldtype": "Section Break", "label": "Interview Details" }, - { - "fieldname": "text_editor_12", - "fieldtype": "Text Editor", - "label": "Interview Summary" - }, { "fieldname": "column_break_10", "fieldtype": "Column Break" @@ -213,12 +208,17 @@ "options": "Exit Interview", "print_hide": 1, "read_only": 1 + }, + { + "fieldname": "interview_summary", + "fieldtype": "Text Editor", + "label": "Interview Summary" } ], "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-12-05 18:56:34.856854", + "modified": "2021-12-07 14:08:29.355390", "modified_by": "Administrator", "module": "HR", "name": "Exit Interview", diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.py b/erpnext/hr/doctype/exit_interview/exit_interview.py index e72c47e8a7..30e19f1c9b 100644 --- a/erpnext/hr/doctype/exit_interview/exit_interview.py +++ b/erpnext/hr/doctype/exit_interview/exit_interview.py @@ -109,7 +109,7 @@ def validate_questionnaire_settings(): if not settings.exit_questionnaire_web_form or not settings.exit_questionnaire_notification_template: frappe.throw( - message=_('Please set {0} and {1} in {2}.').format( + _('Please set {0} and {1} in {2}.').format( frappe.bold('Exit Questionnaire Web Form'), frappe.bold('Notification Template'), get_link_to_form('HR Settings', 'HR Settings')), @@ -122,8 +122,10 @@ def show_email_summary(email_success, email_failure): if email_success: message += _('{0}: {1}').format( frappe.bold('Sent Successfully'), ', '.join(email_success)) + if message and email_failure: + message += '

' if email_failure: - message += '

' + _('{0} due to missing email information for employee(s): {1}').format( + message += _('{0} due to missing email information for employee(s): {1}').format( frappe.bold('Sending Failed'), ', '.join(email_failure)) frappe.msgprint(message, title=_('Exit Questionnaire'), indicator='blue', is_minimizable=True, wide=True) \ No newline at end of file diff --git a/erpnext/hr/doctype/exit_interview/test_exit_interview.py b/erpnext/hr/doctype/exit_interview/test_exit_interview.py index 3a6316c200..8eeb4a1b95 100644 --- a/erpnext/hr/doctype/exit_interview/test_exit_interview.py +++ b/erpnext/hr/doctype/exit_interview/test_exit_interview.py @@ -1,9 +1,89 @@ # Copyright (c) 2021, Frappe Technologies Pvt. Ltd. and Contributors # See license.txt -# import frappe import unittest +import frappe +from frappe.core.doctype.user_permission.test_user_permission import create_user +from frappe.tests.test_webform import create_custom_doctype, create_webform +from frappe.utils import getdate + +from erpnext.hr.doctype.employee.test_employee import make_employee +from erpnext.hr.doctype.exit_interview.exit_interview import send_exit_questionnaire + class TestExitInterview(unittest.TestCase): - pass + def test_duplicate_interview(self): + employee = make_employee('employeeexit1@example.com') + frappe.db.set_value('Employee', employee, 'relieving_date', getdate()) + interview = create_exit_interview(employee) + + doc = frappe.copy_doc(interview) + self.assertRaises(frappe.DuplicateEntryError, doc.save) + + def test_relieving_date_validation(self): + employee = make_employee('employeeexit2@example.com') + + interview = create_exit_interview(employee, save=False) + self.assertRaises(frappe.ValidationError, interview.save) + + # set relieving date + frappe.db.set_value('Employee', employee, 'relieving_date', getdate()) + interview = create_exit_interview(employee) + self.assertTrue(interview.name) + + def test_interview_date_updated_in_employee_master(self): + employee = make_employee('employeeexit3@example.com') + frappe.db.set_value('Employee', employee, 'relieving_date', getdate()) + + interview = create_exit_interview(employee) + interview.status = 'Completed' + interview.employee_status = 'Exit Confirmed' + + # exit interview date updated on submit + interview.submit() + self.assertEqual(frappe.db.get_value('Employee', employee, 'held_on'), interview.date) + + # exit interview reset on cancel + interview.reload() + interview.cancel() + self.assertEqual(frappe.db.get_value('Employee', employee, 'held_on'), None) + + def test_send_exit_questionnaire(self): + create_custom_doctype() + create_webform() + + webform = frappe.db.get_all('Web Form', limit=1) + frappe.db.set_value('HR Settings', 'HR Settings', 'exit_questionnaire_web_form', webform[0].name) + + employee = make_employee('employeeexit3@example.com') + frappe.db.set_value('Employee', employee, 'relieving_date', getdate()) + + interview = create_exit_interview(employee) + send_exit_questionnaire([interview]) + + email_queue = frappe.db.get_all('Email Queue', ['name', 'message'], limit=1) + self.assertTrue('Subject: Exit Questionnaire Notification' in email_queue[0].message) + + def tearDown(self): + frappe.db.rollback() + + +def create_exit_interview(employee, save=True): + interviewer = create_user('test_interviewer1@example.com') + + doc = frappe.get_doc({ + 'doctype': 'Exit Interview', + 'employee': employee, + 'company': '_Test Company', + 'status': 'Pending', + 'date': getdate(), + 'interviewers': [{ + 'interviewer': interviewer.name + }], + 'interview_summary': 'Test' + }) + + if save: + return doc.insert() + return doc From ef38b127ae0f592a17070a55087804cad8a44ec0 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 7 Dec 2021 15:02:00 +0530 Subject: [PATCH 49/77] chore: fix report column widths --- .../report/employee_exits/employee_exits.py | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/erpnext/hr/report/employee_exits/employee_exits.py b/erpnext/hr/report/employee_exits/employee_exits.py index 93252295f3..56cea4917b 100644 --- a/erpnext/hr/report/employee_exits/employee_exits.py +++ b/erpnext/hr/report/employee_exits/employee_exits.py @@ -33,13 +33,39 @@ def get_columns(): 'label': _('Date of Joining'), 'fieldname': 'date_of_joining', 'fieldtype': 'Date', - 'width': 100 + 'width': 120 }, { 'label': _('Relieving Date'), 'fieldname': 'relieving_date', 'fieldtype': 'Date', - 'width': 100 + 'width': 120 + }, + { + 'label': _('Exit Interview'), + 'fieldname': 'exit_interview', + 'fieldtype': 'Link', + 'options': 'Exit Interview', + 'width': 150 + }, + { + 'label': _('Interview Status'), + 'fieldname': 'interview_status', + 'fieldtype': 'Data', + 'width': 130 + }, + { + 'label': _('Final Decision'), + 'fieldname': 'employee_status', + 'fieldtype': 'Data', + 'width': 150 + }, + { + 'label': _('Full and Final Statement'), + 'fieldname': 'full_and_final_statement', + 'fieldtype': 'Link', + 'options': 'Full and Final Statement', + 'width': 180 }, { 'label': _('Department'), @@ -61,32 +87,6 @@ def get_columns(): 'fieldtype': 'Link', 'options': 'Employee', 'width': 120 - }, - { - 'label': _('Exit Interview'), - 'fieldname': 'exit_interview', - 'fieldtype': 'Link', - 'options': 'Exit Interview', - 'width': 98 - }, - { - 'label': _('Interview Status'), - 'fieldname': 'interview_status', - 'fieldtype': 'Data', - 'width': 98 - }, - { - 'label': _('Final Decision'), - 'fieldname': 'employee_status', - 'fieldtype': 'Data', - 'width': 98 - }, - { - 'label': _('Full and Final Statement'), - 'fieldname': 'full_and_final_statement', - 'fieldtype': 'Link', - 'options': 'Full and Final Statement', - 'width': 150 } ] From c305ff911f2db58b40463ad069a8e99fcc1b2cd9 Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 7 Dec 2021 16:22:17 +0530 Subject: [PATCH 50/77] test: Employee Exits Report --- .../report/employee_exits/employee_exits.py | 7 +- .../employee_exits/test_employee_exits.py | 221 ++++++++++++++++++ 2 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 erpnext/hr/report/employee_exits/test_employee_exits.py diff --git a/erpnext/hr/report/employee_exits/employee_exits.py b/erpnext/hr/report/employee_exits/employee_exits.py index 56cea4917b..d0e1ef9a32 100644 --- a/erpnext/hr/report/employee_exits/employee_exits.py +++ b/erpnext/hr/report/employee_exits/employee_exits.py @@ -105,8 +105,9 @@ def get_data(filters): employee.department.as_('department'), employee.designation.as_('designation'), employee.reports_to.as_('reports_to'), interview.name.as_('exit_interview'), interview.status.as_('interview_status'), interview.employee_status.as_('employee_status'), - interview.reference_document_name.as_('questionnaire'), fnf.name.as_('full_and_final_statement') - ).distinct() + interview.reference_document_name.as_('questionnaire'), fnf.name.as_('full_and_final_statement')) + .distinct() + .orderby(employee.relieving_date) .where( ((employee.relieving_date.isnotnull()) | (employee.relieving_date != '')) & ((interview.name.isnull()) | ((interview.name.isnotnull()) & (interview.docstatus != 2))) @@ -158,7 +159,7 @@ def get_conditions(filters, query, employee, interview, fnf): query = query.where((interview.reference_document_name == '') | (interview.reference_document_name.isnull())) if filters.get('fnf_pending'): - query = query.where((fnf.name == '') | (interview.name.isnull())) + query = query.where((fnf.name == '') | (fnf.name.isnull())) return query diff --git a/erpnext/hr/report/employee_exits/test_employee_exits.py b/erpnext/hr/report/employee_exits/test_employee_exits.py new file mode 100644 index 0000000000..1c64b46773 --- /dev/null +++ b/erpnext/hr/report/employee_exits/test_employee_exits.py @@ -0,0 +1,221 @@ +import unittest + +import frappe +from frappe.utils import add_days, getdate + +from erpnext.hr.doctype.employee.test_employee import make_employee +from erpnext.hr.doctype.exit_interview.test_exit_interview import create_exit_interview +from erpnext.hr.doctype.full_and_final_statement.test_full_and_final_statement import create_full_and_final_statement +from erpnext.hr.report.employee_exits.employee_exits import execute + + +class TestEmployeeExits(unittest.TestCase): + @classmethod + def setUpClass(cls): + frappe.db.sql("delete from `tabEmployee` where company='Test Company'") + frappe.db.sql("delete from `tabFull and Final Statement` where company='Test Company'") + frappe.db.sql("delete from `tabExit Interview` where company='Test Company'") + + cls.create_records() + + @classmethod + def tearDownClass(cls): + frappe.db.rollback() + + @classmethod + def create_records(cls): + cls.emp1 = make_employee('employeeexit1@example.com', + company='Test Company', + date_of_joining=getdate('01-10-2021'), + relieving_date=add_days(getdate(), 14), + designation='Accountant' + ) + cls.emp2 = make_employee('employeeexit2@example.com', + company='Test Company', + date_of_joining=getdate('01-12-2021'), + relieving_date=add_days(getdate(), 15), + designation='Accountant' + ) + + cls.emp3 = make_employee('employeeexit3@example.com', + company='Test Company', + date_of_joining=getdate('02-12-2021'), + relieving_date=add_days(getdate(), 29), + designation='Engineer' + ) + cls.emp4 = make_employee('employeeexit4@example.com', + company='Test Company', + date_of_joining=getdate('01-12-2021'), + relieving_date=add_days(getdate(), 30), + designation='Engineer' + ) + + # exit interview for 3 employees only + cls.interview1 = create_exit_interview(cls.emp1) + cls.interview2 = create_exit_interview(cls.emp2) + cls.interview3 = create_exit_interview(cls.emp3) + + # create fnf for some records + cls.fnf1 = create_full_and_final_statement(cls.emp1) + cls.fnf2 = create_full_and_final_statement(cls.emp2) + + # link questionnaire for a few records + # setting employee doctype as reference instead of creating a questionnaire + # since this is just for a test + frappe.db.set_value('Exit Interview', cls.interview1.name, { + 'ref_doctype': 'Employee', + 'reference_document_name': cls.emp1 + }) + + frappe.db.set_value('Exit Interview', cls.interview2.name, { + 'ref_doctype': 'Employee', + 'reference_document_name': cls.emp2 + }) + + frappe.db.set_value('Exit Interview', cls.interview3.name, { + 'ref_doctype': 'Employee', + 'reference_document_name': cls.emp3 + }) + + + def test_employee_exits_summary(self): + filters = { + 'company': 'Test Company', + 'from_date': getdate(), + 'to_date': add_days(getdate(), 15), + 'designation': 'Accountant' + } + + report = execute(filters) + + employee1 = frappe.get_doc('Employee', self.emp1) + employee2 = frappe.get_doc('Employee', self.emp2) + expected_data = [{ + 'employee': employee1.name, + 'employee_name': employee1.employee_name, + 'date_of_joining': employee1.date_of_joining, + 'relieving_date': employee1.relieving_date, + 'department': employee1.department, + 'designation': employee1.designation, + 'reports_to': None, + 'exit_interview': self.interview1.name, + 'interview_status': self.interview1.status, + 'employee_status': '', + 'questionnaire': employee1.name, + 'full_and_final_statement': self.fnf1.name + }, + { + 'employee': employee2.name, + 'employee_name': employee2.employee_name, + 'date_of_joining': employee2.date_of_joining, + 'relieving_date': employee2.relieving_date, + 'department': employee2.department, + 'designation': employee2.designation, + 'reports_to': None, + 'exit_interview': self.interview2.name, + 'interview_status': self.interview2.status, + 'employee_status': '', + 'questionnaire': employee2.name, + 'full_and_final_statement': self.fnf2.name + }] + + self.assertEqual(expected_data, report[1]) # rows + + + def test_pending_exit_interviews_summary(self): + filters = { + 'company': 'Test Company', + 'from_date': getdate(), + 'to_date': add_days(getdate(), 30), + 'exit_interview_pending': 1 + } + + report = execute(filters) + + employee4 = frappe.get_doc('Employee', self.emp4) + expected_data = [{ + 'employee': employee4.name, + 'employee_name': employee4.employee_name, + 'date_of_joining': employee4.date_of_joining, + 'relieving_date': employee4.relieving_date, + 'department': employee4.department, + 'designation': employee4.designation, + 'reports_to': None, + 'exit_interview': None, + 'interview_status': None, + 'employee_status': None, + 'questionnaire': None, + 'full_and_final_statement': None + }] + + self.assertEqual(expected_data, report[1]) # rows + + def test_pending_exit_questionnaire_summary(self): + filters = { + 'company': 'Test Company', + 'from_date': getdate(), + 'to_date': add_days(getdate(), 30), + 'questionnaire_pending': 1 + } + + report = execute(filters) + + employee4 = frappe.get_doc('Employee', self.emp4) + expected_data = [{ + 'employee': employee4.name, + 'employee_name': employee4.employee_name, + 'date_of_joining': employee4.date_of_joining, + 'relieving_date': employee4.relieving_date, + 'department': employee4.department, + 'designation': employee4.designation, + 'reports_to': None, + 'exit_interview': None, + 'interview_status': None, + 'employee_status': None, + 'questionnaire': None, + 'full_and_final_statement': None + }] + + self.assertEqual(expected_data, report[1]) # rows + + + def test_pending_fnf_summary(self): + filters = { + 'company': 'Test Company', + 'fnf_pending': 1 + } + + report = execute(filters) + + employee3 = frappe.get_doc('Employee', self.emp3) + employee4 = frappe.get_doc('Employee', self.emp4) + expected_data = [{ + 'employee': employee3.name, + 'employee_name': employee3.employee_name, + 'date_of_joining': employee3.date_of_joining, + 'relieving_date': employee3.relieving_date, + 'department': employee3.department, + 'designation': employee3.designation, + 'reports_to': None, + 'exit_interview': self.interview3.name, + 'interview_status': self.interview3.status, + 'employee_status': '', + 'questionnaire': employee3.name, + 'full_and_final_statement': None + }, + { + 'employee': employee4.name, + 'employee_name': employee4.employee_name, + 'date_of_joining': employee4.date_of_joining, + 'relieving_date': employee4.relieving_date, + 'department': employee4.department, + 'designation': employee4.designation, + 'reports_to': None, + 'exit_interview': None, + 'interview_status': None, + 'employee_status': None, + 'questionnaire': None, + 'full_and_final_statement': None + }] + + self.assertEqual(expected_data, report[1]) # rows \ No newline at end of file From dcbf0c9eca1ca811a69eb318a0365a38054ac2cc Mon Sep 17 00:00:00 2001 From: Rucha Mahabal Date: Tue, 7 Dec 2021 23:40:10 +0530 Subject: [PATCH 51/77] fix: tests and sider issues --- .../exit_interview/exit_interview.json | 6 +- .../exit_interview/test_exit_interview.py | 30 +++- .../employee_exits/test_employee_exits.py | 145 ++++++++++-------- 3 files changed, 115 insertions(+), 66 deletions(-) diff --git a/erpnext/hr/doctype/exit_interview/exit_interview.json b/erpnext/hr/doctype/exit_interview/exit_interview.json index 86720a105b..989a1b8118 100644 --- a/erpnext/hr/doctype/exit_interview/exit_interview.json +++ b/erpnext/hr/doctype/exit_interview/exit_interview.json @@ -120,8 +120,8 @@ "fieldname": "interviewers", "fieldtype": "Table MultiSelect", "label": "Interviewers", - "options": "Interviewer", - "reqd": 1 + "mandatory_depends_on": "eval:doc.status==='Scheduled';", + "options": "Interviewer" }, { "fetch_from": "employee.date_of_joining", @@ -218,7 +218,7 @@ "index_web_pages_for_search": 1, "is_submittable": 1, "links": [], - "modified": "2021-12-07 14:08:29.355390", + "modified": "2021-12-07 23:39:22.645401", "modified_by": "Administrator", "module": "HR", "name": "Exit Interview", diff --git a/erpnext/hr/doctype/exit_interview/test_exit_interview.py b/erpnext/hr/doctype/exit_interview/test_exit_interview.py index 8eeb4a1b95..b31d593a2d 100644 --- a/erpnext/hr/doctype/exit_interview/test_exit_interview.py +++ b/erpnext/hr/doctype/exit_interview/test_exit_interview.py @@ -4,6 +4,7 @@ import unittest import frappe +from frappe import _ from frappe.core.doctype.user_permission.test_user_permission import create_user from frappe.tests.test_webform import create_custom_doctype, create_webform from frappe.utils import getdate @@ -13,6 +14,9 @@ from erpnext.hr.doctype.exit_interview.exit_interview import send_exit_questionn class TestExitInterview(unittest.TestCase): + def setUp(self): + frappe.db.sql('delete from `tabExit Interview`') + def test_duplicate_interview(self): employee = make_employee('employeeexit1@example.com') frappe.db.set_value('Employee', employee, 'relieving_date', getdate()) @@ -23,6 +27,8 @@ class TestExitInterview(unittest.TestCase): def test_relieving_date_validation(self): employee = make_employee('employeeexit2@example.com') + # unset relieving date + frappe.db.set_value('Employee', employee, 'relieving_date', None) interview = create_exit_interview(employee, save=False) self.assertRaises(frappe.ValidationError, interview.save) @@ -52,9 +58,13 @@ class TestExitInterview(unittest.TestCase): def test_send_exit_questionnaire(self): create_custom_doctype() create_webform() + template = create_notification_template() webform = frappe.db.get_all('Web Form', limit=1) - frappe.db.set_value('HR Settings', 'HR Settings', 'exit_questionnaire_web_form', webform[0].name) + frappe.db.set_value('HR Settings', 'HR Settings', { + 'exit_questionnaire_web_form': webform[0].name, + 'exit_questionnaire_notification_template': template + }) employee = make_employee('employeeexit3@example.com') frappe.db.set_value('Employee', employee, 'relieving_date', getdate()) @@ -87,3 +97,21 @@ def create_exit_interview(employee, save=True): if save: return doc.insert() return doc + + +def create_notification_template(): + template = frappe.db.exists('Email Template', _('Exit Questionnaire Notification')) + if not template: + base_path = frappe.get_app_path('erpnext', 'hr', 'doctype') + response = frappe.read_file(os.path.join(base_path, 'exit_interview/exit_questionnaire_notification_template.html')) + + template = frappe.get_doc({ + 'doctype': 'Email Template', + 'name': _('Exit Questionnaire Notification'), + 'response': response, + 'subject': _('Exit Questionnaire Notification'), + 'owner': frappe.session.user, + }).insert(ignore_permissions=True) + template = template.name + + return template \ No newline at end of file diff --git a/erpnext/hr/report/employee_exits/test_employee_exits.py b/erpnext/hr/report/employee_exits/test_employee_exits.py index 1c64b46773..d7e95a60d0 100644 --- a/erpnext/hr/report/employee_exits/test_employee_exits.py +++ b/erpnext/hr/report/employee_exits/test_employee_exits.py @@ -5,13 +5,16 @@ from frappe.utils import add_days, getdate from erpnext.hr.doctype.employee.test_employee import make_employee from erpnext.hr.doctype.exit_interview.test_exit_interview import create_exit_interview -from erpnext.hr.doctype.full_and_final_statement.test_full_and_final_statement import create_full_and_final_statement +from erpnext.hr.doctype.full_and_final_statement.test_full_and_final_statement import ( + create_full_and_final_statement, +) from erpnext.hr.report.employee_exits.employee_exits import execute class TestEmployeeExits(unittest.TestCase): @classmethod def setUpClass(cls): + create_company() frappe.db.sql("delete from `tabEmployee` where company='Test Company'") frappe.db.sql("delete from `tabFull and Final Statement` where company='Test Company'") frappe.db.sql("delete from `tabExit Interview` where company='Test Company'") @@ -24,26 +27,30 @@ class TestEmployeeExits(unittest.TestCase): @classmethod def create_records(cls): - cls.emp1 = make_employee('employeeexit1@example.com', + cls.emp1 = make_employee( + 'employeeexit1@example.com', company='Test Company', date_of_joining=getdate('01-10-2021'), relieving_date=add_days(getdate(), 14), designation='Accountant' ) - cls.emp2 = make_employee('employeeexit2@example.com', + cls.emp2 = make_employee( + 'employeeexit2@example.com', company='Test Company', date_of_joining=getdate('01-12-2021'), relieving_date=add_days(getdate(), 15), designation='Accountant' ) - cls.emp3 = make_employee('employeeexit3@example.com', + cls.emp3 = make_employee( + 'employeeexit3@example.com', company='Test Company', date_of_joining=getdate('02-12-2021'), relieving_date=add_days(getdate(), 29), designation='Engineer' ) - cls.emp4 = make_employee('employeeexit4@example.com', + cls.emp4 = make_employee( + 'employeeexit4@example.com', company='Test Company', date_of_joining=getdate('01-12-2021'), relieving_date=add_days(getdate(), 30), @@ -90,34 +97,36 @@ class TestEmployeeExits(unittest.TestCase): employee1 = frappe.get_doc('Employee', self.emp1) employee2 = frappe.get_doc('Employee', self.emp2) - expected_data = [{ - 'employee': employee1.name, - 'employee_name': employee1.employee_name, - 'date_of_joining': employee1.date_of_joining, - 'relieving_date': employee1.relieving_date, - 'department': employee1.department, - 'designation': employee1.designation, - 'reports_to': None, - 'exit_interview': self.interview1.name, - 'interview_status': self.interview1.status, - 'employee_status': '', - 'questionnaire': employee1.name, - 'full_and_final_statement': self.fnf1.name - }, - { - 'employee': employee2.name, - 'employee_name': employee2.employee_name, - 'date_of_joining': employee2.date_of_joining, - 'relieving_date': employee2.relieving_date, - 'department': employee2.department, - 'designation': employee2.designation, - 'reports_to': None, - 'exit_interview': self.interview2.name, - 'interview_status': self.interview2.status, - 'employee_status': '', - 'questionnaire': employee2.name, - 'full_and_final_statement': self.fnf2.name - }] + expected_data = [ + { + 'employee': employee1.name, + 'employee_name': employee1.employee_name, + 'date_of_joining': employee1.date_of_joining, + 'relieving_date': employee1.relieving_date, + 'department': employee1.department, + 'designation': employee1.designation, + 'reports_to': None, + 'exit_interview': self.interview1.name, + 'interview_status': self.interview1.status, + 'employee_status': '', + 'questionnaire': employee1.name, + 'full_and_final_statement': self.fnf1.name + }, + { + 'employee': employee2.name, + 'employee_name': employee2.employee_name, + 'date_of_joining': employee2.date_of_joining, + 'relieving_date': employee2.relieving_date, + 'department': employee2.department, + 'designation': employee2.designation, + 'reports_to': None, + 'exit_interview': self.interview2.name, + 'interview_status': self.interview2.status, + 'employee_status': '', + 'questionnaire': employee2.name, + 'full_and_final_statement': self.fnf2.name + } + ] self.assertEqual(expected_data, report[1]) # rows @@ -189,33 +198,45 @@ class TestEmployeeExits(unittest.TestCase): employee3 = frappe.get_doc('Employee', self.emp3) employee4 = frappe.get_doc('Employee', self.emp4) - expected_data = [{ - 'employee': employee3.name, - 'employee_name': employee3.employee_name, - 'date_of_joining': employee3.date_of_joining, - 'relieving_date': employee3.relieving_date, - 'department': employee3.department, - 'designation': employee3.designation, - 'reports_to': None, - 'exit_interview': self.interview3.name, - 'interview_status': self.interview3.status, - 'employee_status': '', - 'questionnaire': employee3.name, - 'full_and_final_statement': None - }, - { - 'employee': employee4.name, - 'employee_name': employee4.employee_name, - 'date_of_joining': employee4.date_of_joining, - 'relieving_date': employee4.relieving_date, - 'department': employee4.department, - 'designation': employee4.designation, - 'reports_to': None, - 'exit_interview': None, - 'interview_status': None, - 'employee_status': None, - 'questionnaire': None, - 'full_and_final_statement': None - }] + expected_data = [ + { + 'employee': employee3.name, + 'employee_name': employee3.employee_name, + 'date_of_joining': employee3.date_of_joining, + 'relieving_date': employee3.relieving_date, + 'department': employee3.department, + 'designation': employee3.designation, + 'reports_to': None, + 'exit_interview': self.interview3.name, + 'interview_status': self.interview3.status, + 'employee_status': '', + 'questionnaire': employee3.name, + 'full_and_final_statement': None + }, + { + 'employee': employee4.name, + 'employee_name': employee4.employee_name, + 'date_of_joining': employee4.date_of_joining, + 'relieving_date': employee4.relieving_date, + 'department': employee4.department, + 'designation': employee4.designation, + 'reports_to': None, + 'exit_interview': None, + 'interview_status': None, + 'employee_status': None, + 'questionnaire': None, + 'full_and_final_statement': None + } + ] - self.assertEqual(expected_data, report[1]) # rows \ No newline at end of file + self.assertEqual(expected_data, report[1]) # rows + + +def create_company(): + if not frappe.db.exists('Company', 'Test Company'): + frappe.get_doc({ + 'doctype': 'Company', + 'company_name': 'Test Company', + 'default_currency': 'INR', + 'country': 'India' + }).insert() \ No newline at end of file From ffa3d45c42649c7cf4db4c12f67b2181901f0f05 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 10 Dec 2021 14:42:38 +0530 Subject: [PATCH 52/77] chore: update stale rules and apply on issues [skip ci] --- .github/stale.yml | 56 ++++++++++++++++++++++++----------------------- 1 file changed, 29 insertions(+), 27 deletions(-) diff --git a/.github/stale.yml b/.github/stale.yml index 9322ae87bf..8b7cb9be3e 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -1,34 +1,36 @@ # Configuration for probot-stale - https://github.com/probot/stale -# Number of days of inactivity before an Issue or Pull Request becomes stale -daysUntilStale: 15 - -# Number of days of inactivity before a stale Issue or Pull Request is closed. -# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. -daysUntilClose: 3 - -# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable -exemptLabels: - - hotfix - -# Set to true to ignore issues in a project (defaults to false) -exemptProjects: false - -# Set to true to ignore issues in a milestone (defaults to false) -exemptMilestones: true - # Label to use when marking as stale staleLabel: inactive -# Comment to post when marking as stale. Set to `false` to disable -markComment: > - This pull request has been automatically marked as stale because it has not had - recent activity. It will be closed within a week if no further activity occurs, but it - only takes a comment to keep a contribution alive :) Also, even if it is closed, - you can always reopen the PR when you're ready. Thank you for contributing. - # Limit the number of actions per hour, from 1-30. Default is 30 -limitPerRun: 30 +limitPerRun: 10 -# Limit to only `issues` or `pulls` -only: pulls +# Set to true to ignore issues in a project (defaults to false) +exemptProjects: true + +# Set to true to ignore issues in a milestone (defaults to false) +exemptMilestones: true + +pulls: + daysUntilStale: 15 + daysUntilClose: 3 + exemptLabels: + - hotfix + markComment: > + This pull request has been automatically marked as inactive because it has + not had recent activity. It will be closed within 3 days if no further + activity occurs, but it only takes a comment to keep a contribution alive + :) Also, even if it is closed, you can always reopen the PR when you're + ready. Thank you for contributing. + +issues: + daysUntilStale: 60 + daysUntilClose: 7 + exemptLabels: + - valid + - to-validate + markComment: > + This issue has been automatically marked as inactive because it has not had + recent activity and it wasn't validated by maintainer team. It will be + closed within a week if no further activity occurs. From bf48f176003e2ab6d43de450d5831d941be44caf Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 10 Dec 2021 15:58:56 +0530 Subject: [PATCH 53/77] chore: new and improved bug report form [skip ci] --- .github/ISSUE_TEMPLATE/bug_report.md | 47 ----------- .github/ISSUE_TEMPLATE/bug_report.yaml | 106 +++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 47 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yaml diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index c145291b57..0000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,47 +0,0 @@ ---- -name: Bug report -about: Report a bug encountered while using ERPNext -labels: bug ---- - - - -## Description of the issue - -## Context information (for bug reports) - -**Output of `bench version`** -``` -(paste here) -``` - -## Steps to reproduce the issue - -1. -2. -3. - -### Observed result - -### Expected result - -### Stacktrace / full error message - -``` -(paste here) -``` - -## Additional information - -OS version / distribution, `ERPNext` install method, etc. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 0000000000..df8fcc2989 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,106 @@ +name: Bug Report +description: Report a bug encountered while using ERPNext +labels: ["bug"] + +body: + - type: markdown + attributes: + value: | + Welcome to ERPNext issue tracker! Before creating an issue, please heed the following: + + 1. This tracker should only be used to report bugs and request features / enhancements to ERPNext + - For questions and general support, checkout the [user manual](https://docs.erpnext.com/) or use [forum](https://discuss.erpnext.com) + - For documentation issues, propose edit on [documentation site](https://docs.erpnext.com/) directly. + 2. When making a bug report, make sure you provide all required information. The easier it is for + maintainers to reproduce, the faster it'll be fixed. + 3. If you think you know what the reason for the bug is, share it with us. Maybe put in a PR 😉 + + - type: textarea + id: bug-info + attributes: + label: Information about bug + description: Also tell us, what did you expect to happen? + placeholder: Please provide as much information as possible. + validations: + required: true + + - type: dropdown + id: version + attributes: + label: Version + description: Affected versions. + multiple: true + options: + - v12 + - v13 + - v14 + - develop + validations: + required: true + + - type: dropdown + id: module + attributes: + label: Module + description: Select affected module of ERPNext. + multiple: true + options: + - accounts + - stock + - buying + - selling + - ecommerce + - manufacturing + - HR + - projects + - support + - assets + - integrations + - quality + - regional + - portal + - agriculture + - education + - non-profit + validations: + required: true + + - type: textarea + id: exact-version + attributes: + label: Version + description: Share exact version number of Frappe and ERPNext you are using. + placeholder: | + Frappe version - + ERPNext Verion - + validations: + required: true + + - type: Installation method + id: install-method + attributes: + label: Module + options: + - docker + - easy-install + - manual install + - FrappeCloud + validations: + required: true + + - type: textarea + id: logs + attributes: + label: Relevant log output / Stack trace / Full Error Message. + description: Please copy and paste any relevant log output. This will be automatically formatted. + render: shell + + + - type: checkboxes + id: terms + attributes: + label: Code of Conduct + description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/frappe/erpnext/blob/develop/CODE_OF_CONDUCT.md) + options: + - label: I agree to follow this project's Code of Conduct + required: true From fcb614d1b763bb950b9c133d0c05f392c3f11b45 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 10 Dec 2021 18:22:55 +0530 Subject: [PATCH 54/77] chore: correct form format for issues --- .github/ISSUE_TEMPLATE/bug_report.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index df8fcc2989..a6e16a03d8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -76,10 +76,10 @@ body: validations: required: true - - type: Installation method + - type: dropdown id: install-method attributes: - label: Module + label: Installation method options: - docker - easy-install From fe597cdd3bb663e81e81be2ba9c4fb708294a0d4 Mon Sep 17 00:00:00 2001 From: Ankush Menat Date: Fri, 10 Dec 2021 18:33:21 +0530 Subject: [PATCH 55/77] chore: remove unused issue tempaltes [skip ci] --- .github/ISSUE_TEMPLATE/feature_request.md | 3 +++ .../question-about-using-erpnext.md | 17 ----------------- 2 files changed, 3 insertions(+), 17 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/question-about-using-erpnext.md diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 6cdad356cd..418bf3c941 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,7 +1,10 @@ --- name: Feature request about: Suggest an idea to improve ERPNext +title: '' labels: feature-request +assignees: '' + ---