diff --git a/erpnext/hooks.py b/erpnext/hooks.py index ba10b58f85..9717bb9b17 100644 --- a/erpnext/hooks.py +++ b/erpnext/hooks.py @@ -245,7 +245,10 @@ doc_events = { "erpnext.portal.utils.set_default_role"] }, "Communication": { - "on_update": "erpnext.support.doctype.service_level_agreement.service_level_agreement.update_hold_time" + "on_update": [ + "erpnext.support.doctype.service_level_agreement.service_level_agreement.update_hold_time", + "erpnext.support.doctype.issue.issue.set_first_response_time" + ] }, ("Sales Taxes and Charges Template", 'Price List'): { "on_update": "erpnext.shopping_cart.doctype.shopping_cart_settings.shopping_cart_settings.validate_cart_settings" diff --git a/erpnext/support/doctype/issue/issue.py b/erpnext/support/doctype/issue/issue.py index dd6d647abc..b9a65b6749 100644 --- a/erpnext/support/doctype/issue/issue.py +++ b/erpnext/support/doctype/issue/issue.py @@ -5,10 +5,10 @@ from __future__ import unicode_literals import frappe import json from frappe import _ -from frappe import utils from frappe.model.document import Document -from frappe.utils import now_datetime -from datetime import datetime, timedelta +from frappe.utils import now_datetime, time_diff_in_seconds, get_datetime, date_diff +from frappe.core.utils import get_parent_doc +from datetime import timedelta from frappe.model.mapper import get_mapped_doc from frappe.utils.user import is_website_user from frappe.email.inbox import link_communication_to_document @@ -212,7 +212,129 @@ def make_issue_from_communication(communication, ignore_communication_links=Fals return issue.name +def get_time_in_timedelta(time): + """ + Converts datetime.time(10, 36, 55, 961454) to datetime.timedelta(seconds=38215) + """ + return timedelta(hours=time.hour, minutes=time.minute, seconds=time.second) + +def set_first_response_time(communication, method): + if communication.get('reference_doctype') == "Issue": + issue = get_parent_doc(communication) + if is_first_response(issue): + first_response_time = calculate_first_response_time(issue, get_datetime(issue.first_responded_on)) + issue.db_set("first_response_time", first_response_time) + +def is_first_response(issue): + responses = frappe.get_all('Communication', filters = {'reference_name': issue.name, 'sent_or_received': 'Sent'}) + if len(responses) == 1: + return True + return False + +def calculate_first_response_time(issue, first_responded_on): + issue_creation_date = issue.creation + issue_creation_time = get_time_in_seconds(issue_creation_date) + first_responded_on_in_seconds = get_time_in_seconds(first_responded_on) + support_hours = frappe.get_cached_doc("Service Level Agreement", issue.service_level_agreement).support_and_resolution + + if issue_creation_date.day == first_responded_on.day: + if is_work_day(issue_creation_date, support_hours): + start_time, end_time = get_working_hours(issue_creation_date, support_hours) + + # issue creation and response on the same day during working hours + if is_during_working_hours(issue_creation_date, support_hours) and is_during_working_hours(first_responded_on, support_hours): + return get_elapsed_time(issue_creation_date, first_responded_on) + + # issue creation is during working hours, but first response was after working hours + elif is_during_working_hours(issue_creation_date, support_hours): + return get_elapsed_time(issue_creation_time, end_time) + + # issue creation was before working hours but first response is during working hours + elif is_during_working_hours(first_responded_on, support_hours): + return get_elapsed_time(start_time, first_responded_on_in_seconds) + + # both issue creation and first response were after working hours + else: + return 1.0 # this should ideally be zero, but it gets reset when the next response is sent if the value is zero + + else: + return 1.0 + + else: + # response on the next day + if date_diff(first_responded_on, issue_creation_date) == 1: + first_response_time = 0 + else: + first_response_time = calculate_initial_frt(issue_creation_date, date_diff(first_responded_on, issue_creation_date)- 1, support_hours) + + # time taken on day of issue creation + if is_work_day(issue_creation_date, support_hours): + start_time, end_time = get_working_hours(issue_creation_date, support_hours) + + if is_during_working_hours(issue_creation_date, support_hours): + first_response_time += get_elapsed_time(issue_creation_time, end_time) + elif is_before_working_hours(issue_creation_date, support_hours): + first_response_time += get_elapsed_time(start_time, end_time) + + # time taken on day of first response + if is_work_day(first_responded_on, support_hours): + start_time, end_time = get_working_hours(first_responded_on, support_hours) + + if is_during_working_hours(first_responded_on, support_hours): + first_response_time += get_elapsed_time(start_time, first_responded_on_in_seconds) + elif not is_before_working_hours(first_responded_on, support_hours): + first_response_time += get_elapsed_time(start_time, end_time) + + if first_response_time: + return first_response_time + else: + return 1.0 + +def get_time_in_seconds(date): + return timedelta(hours=date.hour, minutes=date.minute, seconds=date.second) + +def get_working_hours(date, support_hours): + if is_work_day(date, support_hours): + weekday = frappe.utils.get_weekday(date) + for day in support_hours: + if day.workday == weekday: + return day.start_time, day.end_time + +def is_work_day(date, support_hours): + weekday = frappe.utils.get_weekday(date) + for day in support_hours: + if day.workday == weekday: + return True + return False + +def is_during_working_hours(date, support_hours): + start_time, end_time = get_working_hours(date, support_hours) + time = get_time_in_seconds(date) + if time >= start_time and time <= end_time: + return True + return False + +def get_elapsed_time(start_time, end_time): + return round(time_diff_in_seconds(end_time, start_time), 2) + +def calculate_initial_frt(issue_creation_date, days_in_between, support_hours): + initial_frt = 0 + for i in range(days_in_between): + date = issue_creation_date + timedelta(days = (i+1)) + if is_work_day(date, support_hours): + start_time, end_time = get_working_hours(date, support_hours) + initial_frt += get_elapsed_time(start_time, end_time) + + return initial_frt + +def is_before_working_hours(date, support_hours): + start_time, end_time = get_working_hours(date, support_hours) + time = get_time_in_seconds(date) + if time < start_time: + return True + return False + def get_holidays(holiday_list_name): holiday_list = frappe.get_cached_doc("Holiday List", holiday_list_name) holidays = [holiday.holiday_date for holiday in holiday_list.holidays] - return holidays \ No newline at end of file + return holidays diff --git a/erpnext/support/doctype/issue/test_issue.py b/erpnext/support/doctype/issue/test_issue.py index 7b9b1446d4..84f8c398be 100644 --- a/erpnext/support/doctype/issue/test_issue.py +++ b/erpnext/support/doctype/issue/test_issue.py @@ -5,16 +5,18 @@ from __future__ import unicode_literals import frappe import unittest from erpnext.support.doctype.service_level_agreement.test_service_level_agreement import create_service_level_agreements_for_issues -from frappe.utils import now_datetime, get_datetime, flt +from frappe.core.doctype.user_permission.test_user_permission import create_user +from frappe.utils import get_datetime, flt import datetime from datetime import timedelta -class TestIssue(unittest.TestCase): +class TestSetUp(unittest.TestCase): def setUp(self): frappe.db.sql("delete from `tabService Level Agreement`") frappe.db.set_value("Support Settings", None, "track_service_level_agreement", 1) create_service_level_agreements_for_issues() +class TestIssue(TestSetUp): def test_response_time_and_resolution_time_based_on_different_sla(self): creation = datetime.datetime(2019, 3, 4, 12, 0) @@ -133,6 +135,223 @@ class TestIssue(unittest.TestCase): issue.reload() self.assertEqual(flt(issue.total_hold_time, 2), 2700) +class TestFirstResponseTime(TestSetUp): + # working hours used in all cases: Mon-Fri, 10am to 6pm + # all dates are in the mm-dd-yyyy format + + # issue creation and first response are on the same day + def test_first_response_time_case1(self): + """ + Test frt when issue creation and first response are during working hours on the same day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 11:00"), get_datetime("06-28-2021 12:00")) + self.assertEqual(issue.first_response_time, 3600.0) + + def test_first_response_time_case2(self): + """ + Test frt when issue creation was during working hours, but first response is sent after working hours on the same day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 12:00"), get_datetime("06-28-2021 20:00")) + self.assertEqual(issue.first_response_time, 21600.0) + + def test_first_response_time_case3(self): + """ + Test frt when issue creation was before working hours but first response is sent during working hours on the same day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 6:00"), get_datetime("06-28-2021 12:00")) + self.assertEqual(issue.first_response_time, 7200.0) + + def test_first_response_time_case4(self): + """ + Test frt when both issue creation and first response were after working hours on the same day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 19:00"), get_datetime("06-28-2021 20:00")) + self.assertEqual(issue.first_response_time, 1.0) + + def test_first_response_time_case5(self): + """ + Test frt when both issue creation and first response are on the same day, but it's not a work day. + """ + issue = create_issue_and_communication(get_datetime("06-27-2021 10:00"), get_datetime("06-27-2021 11:00")) + self.assertEqual(issue.first_response_time, 1.0) + + # issue creation and first response are on consecutive days + def test_first_response_time_case6(self): + """ + Test frt when the issue was created before working hours and the first response is also sent before working hours, but on the next day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 6:00"), get_datetime("06-29-2021 6:00")) + self.assertEqual(issue.first_response_time, 28800.0) + + def test_first_response_time_case7(self): + """ + Test frt when the issue was created before working hours and the first response is sent during working hours, but on the next day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 6:00"), get_datetime("06-29-2021 11:00")) + self.assertEqual(issue.first_response_time, 32400.0) + + def test_first_response_time_case8(self): + """ + Test frt when the issue was created before working hours and the first response is sent after working hours, but on the next day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 6:00"), get_datetime("06-29-2021 20:00")) + self.assertEqual(issue.first_response_time, 57600.0) + + def test_first_response_time_case9(self): + """ + Test frt when the issue was created before working hours and the first response is sent on the next day, which is not a work day. + """ + issue = create_issue_and_communication(get_datetime("06-25-2021 6:00"), get_datetime("06-26-2021 11:00")) + self.assertEqual(issue.first_response_time, 28800.0) + + def test_first_response_time_case10(self): + """ + Test frt when the issue was created during working hours and the first response is sent before working hours, but on the next day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 12:00"), get_datetime("06-29-2021 6:00")) + self.assertEqual(issue.first_response_time, 21600.0) + + def test_first_response_time_case11(self): + """ + Test frt when the issue was created during working hours and the first response is also sent during working hours, but on the next day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 12:00"), get_datetime("06-29-2021 11:00")) + self.assertEqual(issue.first_response_time, 25200.0) + + def test_first_response_time_case12(self): + """ + Test frt when the issue was created during working hours and the first response is sent after working hours, but on the next day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 12:00"), get_datetime("06-29-2021 20:00")) + self.assertEqual(issue.first_response_time, 50400.0) + + def test_first_response_time_case13(self): + """ + Test frt when the issue was created during working hours and the first response is sent on the next day, which is not a work day. + """ + issue = create_issue_and_communication(get_datetime("06-25-2021 12:00"), get_datetime("06-26-2021 11:00")) + self.assertEqual(issue.first_response_time, 21600.0) + + def test_first_response_time_case14(self): + """ + Test frt when the issue was created after working hours and the first response is sent before working hours, but on the next day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 20:00"), get_datetime("06-29-2021 6:00")) + self.assertEqual(issue.first_response_time, 1.0) + + def test_first_response_time_case15(self): + """ + Test frt when the issue was created after working hours and the first response is sent during working hours, but on the next day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 20:00"), get_datetime("06-29-2021 11:00")) + self.assertEqual(issue.first_response_time, 3600.0) + + def test_first_response_time_case16(self): + """ + Test frt when the issue was created after working hours and the first response is also sent after working hours, but on the next day. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 20:00"), get_datetime("06-29-2021 20:00")) + self.assertEqual(issue.first_response_time, 28800.0) + + def test_first_response_time_case17(self): + """ + Test frt when the issue was created after working hours and the first response is sent on the next day, which is not a work day. + """ + issue = create_issue_and_communication(get_datetime("06-25-2021 20:00"), get_datetime("06-26-2021 11:00")) + self.assertEqual(issue.first_response_time, 1.0) + + # issue creation and first response are a few days apart + def test_first_response_time_case18(self): + """ + Test frt when the issue was created before working hours and the first response is also sent before working hours, but after a few days. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 6:00"), get_datetime("07-01-2021 6:00")) + self.assertEqual(issue.first_response_time, 86400.0) + + def test_first_response_time_case19(self): + """ + Test frt when the issue was created before working hours and the first response is sent during working hours, but after a few days. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 6:00"), get_datetime("07-01-2021 11:00")) + self.assertEqual(issue.first_response_time, 90000.0) + + def test_first_response_time_case20(self): + """ + Test frt when the issue was created before working hours and the first response is sent after working hours, but after a few days. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 6:00"), get_datetime("07-01-2021 20:00")) + self.assertEqual(issue.first_response_time, 115200.0) + + def test_first_response_time_case21(self): + """ + Test frt when the issue was created before working hours and the first response is sent after a few days, on a holiday. + """ + issue = create_issue_and_communication(get_datetime("06-25-2021 6:00"), get_datetime("06-27-2021 11:00")) + self.assertEqual(issue.first_response_time, 28800.0) + + def test_first_response_time_case22(self): + """ + Test frt when the issue was created during working hours and the first response is sent before working hours, but after a few days. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 12:00"), get_datetime("07-01-2021 6:00")) + self.assertEqual(issue.first_response_time, 79200.0) + + def test_first_response_time_case23(self): + """ + Test frt when the issue was created during working hours and the first response is also sent during working hours, but after a few days. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 12:00"), get_datetime("07-01-2021 11:00")) + self.assertEqual(issue.first_response_time, 82800.0) + + def test_first_response_time_case24(self): + """ + Test frt when the issue was created during working hours and the first response is sent after working hours, but after a few days. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 12:00"), get_datetime("07-01-2021 20:00")) + self.assertEqual(issue.first_response_time, 108000.0) + + def test_first_response_time_case25(self): + """ + Test frt when the issue was created during working hours and the first response is sent after a few days, on a holiday. + """ + issue = create_issue_and_communication(get_datetime("06-25-2021 12:00"), get_datetime("06-27-2021 11:00")) + self.assertEqual(issue.first_response_time, 21600.0) + + def test_first_response_time_case26(self): + """ + Test frt when the issue was created after working hours and the first response is sent before working hours, but after a few days. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 20:00"), get_datetime("07-01-2021 6:00")) + self.assertEqual(issue.first_response_time, 57600.0) + + def test_first_response_time_case27(self): + """ + Test frt when the issue was created after working hours and the first response is sent during working hours, but after a few days. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 20:00"), get_datetime("07-01-2021 11:00")) + self.assertEqual(issue.first_response_time, 61200.0) + + def test_first_response_time_case28(self): + """ + Test frt when the issue was created after working hours and the first response is also sent after working hours, but after a few days. + """ + issue = create_issue_and_communication(get_datetime("06-28-2021 20:00"), get_datetime("07-01-2021 20:00")) + self.assertEqual(issue.first_response_time, 86400.0) + + def test_first_response_time_case29(self): + """ + Test frt when the issue was created after working hours and the first response is sent after a few days, on a holiday. + """ + issue = create_issue_and_communication(get_datetime("06-25-2021 20:00"), get_datetime("06-27-2021 11:00")) + self.assertEqual(issue.first_response_time, 1.0) + +def create_issue_and_communication(issue_creation, first_responded_on): + issue = make_issue(issue_creation, index=1) + sender = create_user("test@admin.com") + create_communication(issue.name, sender.email, "Sent", first_responded_on) + issue.reload() + + return issue def make_issue(creation=None, customer=None, index=0, priority=None, issue_type=None): issue = frappe.get_doc({ @@ -185,7 +404,7 @@ def create_territory(territory): def create_communication(reference_name, sender, sent_or_received, creation): - issue = frappe.get_doc({ + communication = frappe.get_doc({ "doctype": "Communication", "communication_type": "Communication", "communication_medium": "Email", @@ -199,4 +418,4 @@ def create_communication(reference_name, sender, sent_or_received, creation): "creation": creation, "reference_name": reference_name }) - issue.save() + communication.save() \ No newline at end of file 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 865fadc97c..7bc97d6022 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 @@ -339,16 +339,6 @@ def create_service_level_agreement(default_service_level_agreement, holiday_list "workday": "Friday", "start_time": "10:00:00", "end_time": "18:00:00", - }, - { - "workday": "Saturday", - "start_time": "10:00:00", - "end_time": "18:00:00", - }, - { - "workday": "Sunday", - "start_time": "10:00:00", - "end_time": "18:00:00", } ] })