fix(Issue): Calculate first_response_time based on working hours (#25991)
diff --git a/erpnext/hooks.py b/erpnext/hooks.py
index ba10b58..9717bb9 100644
--- a/erpnext/hooks.py
+++ b/erpnext/hooks.py
@@ -245,7 +245,10 @@
"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 dd6d647..b9a65b6 100644
--- a/erpnext/support/doctype/issue/issue.py
+++ b/erpnext/support/doctype/issue/issue.py
@@ -5,10 +5,10 @@
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 @@
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 7b9b144..84f8c39 100644
--- a/erpnext/support/doctype/issue/test_issue.py
+++ b/erpnext/support/doctype/issue/test_issue.py
@@ -5,16 +5,18 @@
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 @@
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_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 @@
"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 865fadc..7bc97d6 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 @@
"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",
}
]
})