feat: Consider business hours while generating the reports (#4330)

* feat: Consider business hours while generating the reports
This commit is contained in:
Aswin Dev P.S 2022-04-08 12:48:18 +05:30 committed by GitHub
parent 57359be37e
commit d5536d65f7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 241 additions and 48 deletions

View file

@ -125,6 +125,9 @@ gem 'procore-sift'
gem 'email_reply_trimmer' gem 'email_reply_trimmer'
gem 'html2text' gem 'html2text'
# to calculate working hours
gem 'working_hours'
group :production, :staging do group :production, :staging do
# we dont want request timing out in development while using byebug # we dont want request timing out in development while using byebug
gem 'rack-timeout' gem 'rack-timeout'

View file

@ -636,6 +636,9 @@ GEM
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
wisper (2.0.0) wisper (2.0.0)
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.5.4) zeitwerk (2.5.4)
PLATFORMS PLATFORMS
@ -746,6 +749,7 @@ DEPENDENCIES
webpacker (~> 5.x) webpacker (~> 5.x)
webpush webpush
wisper (= 2.0.0) wisper (= 2.0.0)
working_hours
RUBY VERSION RUBY VERSION
ruby 3.0.2p107 ruby 3.0.2p107

View file

@ -47,36 +47,36 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
raise Pundit::NotAuthorizedError unless Current.account_user.administrator? raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end end
def current_summary_params def common_params
{ {
type: params[:type].to_sym, type: params[:type].to_sym,
id: params[:id], id: params[:id],
since: range[:current][:since], group_by: params[:group_by],
until: range[:current][:until], business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
group_by: params[:group_by]
} }
end end
def current_summary_params
common_params.merge({
since: range[:current][:since],
until: range[:current][:until]
})
end
def previous_summary_params def previous_summary_params
{ common_params.merge({
type: params[:type].to_sym, since: range[:previous][:since],
id: params[:id], until: range[:previous][:until]
since: range[:previous][:since], })
until: range[:previous][:until],
group_by: params[:group_by]
}
end end
def report_params def report_params
{ common_params.merge({
metric: params[:metric], metric: params[:metric],
type: params[:type].to_sym, since: params[:since],
since: params[:since], until: params[:until],
until: params[:until], timezone_offset: params[:timezone_offset]
id: params[:id], })
group_by: params[:group_by],
timezone_offset: params[:timezone_offset]
}
end end
def conversation_params def conversation_params

View file

@ -33,17 +33,23 @@ module ReportHelper
end end
def avg_first_response_time def avg_first_response_time
(get_grouped_values scope.reporting_events.where(name: 'first_response')).average(:value) grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response'))
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
grouped_reporting_events.average(:value)
end end
def avg_resolution_time def avg_resolution_time
(get_grouped_values scope.reporting_events.where(name: 'conversation_resolved')).average(:value) grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'conversation_resolved'))
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
grouped_reporting_events.average(:value)
end end
def avg_resolution_time_summary def avg_resolution_time_summary
avg_rt = scope.reporting_events reporting_events = scope.reporting_events
.where(name: 'conversation_resolved', created_at: range) .where(name: 'conversation_resolved', created_at: range)
.average(:value) avg_rt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value)
return 0 if avg_rt.blank? return 0 if avg_rt.blank?
@ -51,9 +57,9 @@ module ReportHelper
end end
def avg_first_response_time_summary def avg_first_response_time_summary
avg_frt = scope.reporting_events reporting_events = scope.reporting_events
.where(name: 'first_response', created_at: range) .where(name: 'first_response', created_at: range)
.average(:value) avg_frt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value)
return 0 if avg_frt.blank? return 0 if avg_frt.blank?

View file

@ -0,0 +1,50 @@
module ReportingEventHelper
def business_hours(inbox, from, to)
return 0 unless inbox.working_hours_enabled?
inbox_working_hours = configure_working_hours(inbox.working_hours)
return 0 if inbox_working_hours.blank?
# Configure working hours
WorkingHours::Config.working_hours = inbox_working_hours
# Configure timezone
WorkingHours::Config.time_zone = inbox.timezone
# Use inbox timezone to change from & to values.
from_in_inbox_timezone = from.in_time_zone(inbox.timezone).to_time
to_in_inbox_timezone = to.in_time_zone(inbox.timezone).to_time
from_in_inbox_timezone.working_time_until(to_in_inbox_timezone)
end
private
def configure_working_hours(working_hours)
working_hours.each_with_object({}) do |working_hour, object|
object[day(working_hour.day_of_week)] = working_hour_range(working_hour) unless working_hour.closed_all_day?
end
end
def day(day_of_week)
week_days = {
0 => :sun,
1 => :mon,
2 => :tue,
3 => :wed,
4 => :thu,
5 => :fri,
6 => :sat
}
week_days[day_of_week]
end
def working_hour_range(working_hour)
{ format_time(working_hour.open_hour, working_hour.open_minutes) => format_time(working_hour.close_hour, working_hour.close_minutes) }
end
def format_time(hour, minute)
hour = hour < 10 ? "0#{hour}" : hour
minute = minute < 10 ? "0#{minute}" : minute
"#{hour}:#{minute}"
end
end

View file

@ -8,7 +8,15 @@ class ReportsAPI extends ApiClient {
super('reports', { accountScoped: true, apiVersion: 'v2' }); super('reports', { accountScoped: true, apiVersion: 'v2' });
} }
getReports(metric, since, until, type = 'account', id, group_by) { getReports(
metric,
since,
until,
type = 'account',
id,
group_by,
business_hours
) {
return axios.get(`${this.url}`, { return axios.get(`${this.url}`, {
params: { params: {
metric, metric,
@ -17,12 +25,13 @@ class ReportsAPI extends ApiClient {
type, type,
id, id,
group_by, group_by,
business_hours,
timezone_offset: getTimeOffset(), timezone_offset: getTimeOffset(),
}, },
}); });
} }
getSummary(since, until, type = 'account', id, group_by) { getSummary(since, until, type = 'account', id, group_by, business_hours) {
return axios.get(`${this.url}/summary`, { return axios.get(`${this.url}/summary`, {
params: { params: {
since, since,
@ -30,6 +39,7 @@ class ReportsAPI extends ApiClient {
type, type,
id, id,
group_by, group_by,
business_hours,
}, },
}); });
} }

View file

@ -78,5 +78,10 @@
font-size: $font-size-default; font-size: $font-size-default;
color: $color-gray; color: $color-gray;
} }
.business-hours {
margin: $space-normal;
text-align: center;
}
} }
} }

View file

@ -25,3 +25,21 @@
align-items: center; align-items: center;
display: flex; display: flex;
} }
.business-hours {
align-items: center;
display: flex;
justify-content: end;
margin-bottom: var(--space-normal);
margin-left: auto;
padding-right: var(--space-normal);
}
.business-hours-text {
font-size: var(--font-size-small);
}
.switch {
margin-bottom: var(--space-zero);
margin-left: var(--space-small);
}

View file

@ -78,7 +78,8 @@
{ "id": 2, "groupBy": "Week" }, { "id": 2, "groupBy": "Week" },
{ "id": 3, "groupBy": "Month" }, { "id": 3, "groupBy": "Month" },
{ "id": 4, "groupBy": "Year" } { "id": 4, "groupBy": "Year" }
] ],
"BUSINESS_HOURS": "Business Hours"
}, },
"AGENT_REPORTS": { "AGENT_REPORTS": {
"HEADER": "Agents Overview", "HEADER": "Agents Overview",

View file

@ -15,6 +15,7 @@
:filter-items-list="filterItemsList" :filter-items-list="filterItemsList"
@date-range-change="onDateRangeChange" @date-range-change="onDateRangeChange"
@filter-change="onFilterChange" @filter-change="onFilterChange"
@business-hours-toggle="onBusinessHoursToggle"
/> />
<div class="row"> <div class="row">
<woot-report-stats-card <woot-report-stats-card
@ -79,6 +80,7 @@ export default {
groupBy: GROUP_BY_FILTER[1], groupBy: GROUP_BY_FILTER[1],
filterItemsList: this.$t('REPORT.GROUP_BY_DAY_OPTIONS'), filterItemsList: this.$t('REPORT.GROUP_BY_DAY_OPTIONS'),
selectedGroupByFilter: {}, selectedGroupByFilter: {},
businessHours: false,
}; };
}, },
computed: { computed: {
@ -166,21 +168,23 @@ export default {
}, },
methods: { methods: {
fetchAllData() { fetchAllData() {
const { from, to, groupBy } = this; const { from, to, groupBy, businessHours } = this;
this.$store.dispatch('fetchAccountSummary', { this.$store.dispatch('fetchAccountSummary', {
from, from,
to, to,
groupBy: groupBy.period, groupBy: groupBy.period,
businessHours,
}); });
this.fetchChartData(); this.fetchChartData();
}, },
fetchChartData() { fetchChartData() {
const { from, to, groupBy } = this; const { from, to, groupBy, businessHours } = this;
this.$store.dispatch('fetchAccountReport', { this.$store.dispatch('fetchAccountReport', {
metric: this.metrics[this.currentSelection].KEY, metric: this.metrics[this.currentSelection].KEY,
from, from,
to, to,
groupBy: groupBy.period, groupBy: groupBy.period,
businessHours,
}); });
}, },
downloadAgentReports() { downloadAgentReports() {
@ -226,6 +230,10 @@ export default {
return this.$t('REPORT.GROUP_BY_DAY_OPTIONS'); return this.$t('REPORT.GROUP_BY_DAY_OPTIONS');
} }
}, },
onBusinessHoursToggle(value) {
this.businessHours = value;
this.fetchAllData();
},
}, },
}; };
</script> </script>

View file

@ -61,6 +61,12 @@
@input="handleAgentsFilterSelection" @input="handleAgentsFilterSelection"
/> />
</div> </div>
<div class="small-12 medium-3 business-hours">
<span class="business-hours-text">{{ $t('REPORT.BUSINESS_HOURS') }}</span>
<span>
<woot-switch v-model="businessHoursSelected" />
</span>
</div>
</div> </div>
</template> </template>
<script> <script>
@ -105,6 +111,7 @@ export default {
customDateRange: [new Date(), new Date()], customDateRange: [new Date(), new Date()],
currentSelectedFilter: null, currentSelectedFilter: null,
selectedAgents: [], selectedAgents: [],
businessHoursSelected: false,
}; };
}, },
computed: { computed: {
@ -153,6 +160,9 @@ export default {
filterItemsList() { filterItemsList() {
this.currentSelectedFilter = this.selectedGroupByFilter; this.currentSelectedFilter = this.selectedGroupByFilter;
}, },
businessHoursSelected() {
this.$emit('business-hours-toggle', this.businessHoursSelected);
},
}, },
mounted() { mounted() {
this.onDateRangeChange(); this.onDateRangeChange();

View file

@ -145,6 +145,12 @@
@input="changeGroupByFilterSelection" @input="changeGroupByFilterSelection"
/> />
</div> </div>
<div class="small-12 medium-3 business-hours">
<span class="business-hours-text">{{ $t('REPORT.BUSINESS_HOURS') }}</span>
<span>
<woot-switch v-model="businessHoursSelected" />
</span>
</div>
</div> </div>
</template> </template>
<script> <script>
@ -188,6 +194,7 @@ export default {
dateRange: this.$t('REPORT.DATE_RANGE'), dateRange: this.$t('REPORT.DATE_RANGE'),
customDateRange: [new Date(), new Date()], customDateRange: [new Date(), new Date()],
currentSelectedGroupByFilter: null, currentSelectedGroupByFilter: null,
businessHoursSelected: false,
}; };
}, },
computed: { computed: {
@ -249,6 +256,9 @@ export default {
groupByFilterItemsList() { groupByFilterItemsList() {
this.currentSelectedGroupByFilter = this.selectedGroupByFilter; this.currentSelectedGroupByFilter = this.selectedGroupByFilter;
}, },
businessHoursSelected() {
this.$emit('business-hours-toggle', this.businessHoursSelected);
},
}, },
mounted() { mounted() {
this.onDateRangeChange(); this.onDateRangeChange();

View file

@ -17,6 +17,7 @@
@date-range-change="onDateRangeChange" @date-range-change="onDateRangeChange"
@filter-change="onFilterChange" @filter-change="onFilterChange"
@group-by-filter-change="onGroupByFilterChange" @group-by-filter-change="onGroupByFilterChange"
@business-hours-toggle="onBusinessHoursToggle"
/> />
<div> <div>
<div v-if="filterItemsList.length" class="row"> <div v-if="filterItemsList.length" class="row">
@ -100,6 +101,7 @@ export default {
groupBy: GROUP_BY_FILTER[1], groupBy: GROUP_BY_FILTER[1],
groupByfilterItemsList: this.$t('REPORT.GROUP_BY_DAY_OPTIONS'), groupByfilterItemsList: this.$t('REPORT.GROUP_BY_DAY_OPTIONS'),
selectedGroupByFilter: null, selectedGroupByFilter: null,
businessHours: false,
}; };
}, },
computed: { computed: {
@ -202,19 +204,20 @@ export default {
methods: { methods: {
fetchAllData() { fetchAllData() {
if (this.selectedFilter) { if (this.selectedFilter) {
const { from, to, groupBy } = this; const { from, to, groupBy, businessHours } = this;
this.$store.dispatch('fetchAccountSummary', { this.$store.dispatch('fetchAccountSummary', {
from, from,
to, to,
type: this.type, type: this.type,
id: this.selectedFilter.id, id: this.selectedFilter.id,
groupBy: groupBy.period, groupBy: groupBy.period,
businessHours,
}); });
this.fetchChartData(); this.fetchChartData();
} }
}, },
fetchChartData() { fetchChartData() {
const { from, to, groupBy } = this; const { from, to, groupBy, businessHours } = this;
this.$store.dispatch('fetchAccountReport', { this.$store.dispatch('fetchAccountReport', {
metric: this.metrics[this.currentSelection].KEY, metric: this.metrics[this.currentSelection].KEY,
from, from,
@ -222,6 +225,7 @@ export default {
type: this.type, type: this.type,
id: this.selectedFilter.id, id: this.selectedFilter.id,
groupBy: groupBy.period, groupBy: groupBy.period,
businessHours,
}); });
}, },
downloadReports() { downloadReports() {
@ -288,6 +292,10 @@ export default {
return this.$t('REPORT.GROUP_BY_DAY_OPTIONS'); return this.$t('REPORT.GROUP_BY_DAY_OPTIONS');
} }
}, },
onBusinessHoursToggle(value) {
this.businessHours = value;
this.fetchAllData();
},
}, },
}; };
</script> </script>

View file

@ -42,7 +42,8 @@ export const actions = {
reportObj.to, reportObj.to,
reportObj.type, reportObj.type,
reportObj.id, reportObj.id,
reportObj.groupBy reportObj.groupBy,
reportObj.businessHours
).then(accountReport => { ).then(accountReport => {
let { data } = accountReport; let { data } = accountReport;
data = data.filter( data = data.filter(
@ -69,7 +70,8 @@ export const actions = {
reportObj.to, reportObj.to,
reportObj.type, reportObj.type,
reportObj.id, reportObj.id,
reportObj.groupBy reportObj.groupBy,
reportObj.businessHours
) )
.then(accountSummary => { .then(accountSummary => {
commit(types.default.SET_ACCOUNT_SUMMARY, accountSummary.data); commit(types.default.SET_ACCOUNT_SUMMARY, accountSummary.data);

View file

@ -1,4 +1,5 @@
class ReportingEventListener < BaseListener class ReportingEventListener < BaseListener
include ReportingEventHelper
def conversation_resolved(event) def conversation_resolved(event)
conversation = extract_conversation_and_account(event)[0] conversation = extract_conversation_and_account(event)[0]
time_to_resolve = conversation.updated_at.to_i - conversation.created_at.to_i time_to_resolve = conversation.updated_at.to_i - conversation.created_at.to_i
@ -6,10 +7,14 @@ class ReportingEventListener < BaseListener
reporting_event = ReportingEvent.new( reporting_event = ReportingEvent.new(
name: 'conversation_resolved', name: 'conversation_resolved',
value: time_to_resolve, value: time_to_resolve,
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at,
conversation.updated_at),
account_id: conversation.account_id, account_id: conversation.account_id,
inbox_id: conversation.inbox_id, inbox_id: conversation.inbox_id,
user_id: conversation.assignee_id, user_id: conversation.assignee_id,
conversation_id: conversation.id conversation_id: conversation.id,
event_start_time: conversation.created_at,
event_end_time: conversation.updated_at
) )
reporting_event.save reporting_event.save
end end
@ -22,10 +27,14 @@ class ReportingEventListener < BaseListener
reporting_event = ReportingEvent.new( reporting_event = ReportingEvent.new(
name: 'first_response', name: 'first_response',
value: first_response_time, value: first_response_time,
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at,
message.created_at),
account_id: conversation.account_id, account_id: conversation.account_id,
inbox_id: conversation.inbox_id, inbox_id: conversation.inbox_id,
user_id: conversation.assignee_id, user_id: conversation.assignee_id,
conversation_id: conversation.id conversation_id: conversation.id,
event_start_time: conversation.created_at,
event_end_time: message.created_at
) )
reporting_event.save reporting_event.save
end end

View file

@ -2,15 +2,18 @@
# #
# Table name: reporting_events # Table name: reporting_events
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# name :string # event_end_time :datetime
# value :float # event_start_time :datetime
# created_at :datetime not null # name :string
# updated_at :datetime not null # value :float
# account_id :integer # value_in_business_hours :float
# conversation_id :integer # created_at :datetime not null
# inbox_id :integer # updated_at :datetime not null
# user_id :integer # account_id :integer
# conversation_id :integer
# inbox_id :integer
# user_id :integer
# #
# Indexes # Indexes
# #

View file

@ -0,0 +1,9 @@
class AddValueInBusinessHoursToReportingEvent < ActiveRecord::Migration[6.1]
def change
change_table :reporting_events, bulk: true do |t|
t.float :value_in_business_hours, default: nil
t.datetime :event_start_time, default: nil
t.datetime :event_end_time, default: nil
end
end
end

View file

@ -657,6 +657,9 @@ ActiveRecord::Schema.define(version: 2022_04_05_092033) do
t.integer "conversation_id" t.integer "conversation_id"
t.datetime "created_at", precision: 6, null: false t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false
t.float "value_in_business_hours"
t.datetime "event_start_time"
t.datetime "event_end_time"
t.index ["account_id"], name: "index_reporting_events_on_account_id" t.index ["account_id"], name: "index_reporting_events_on_account_id"
t.index ["created_at"], name: "index_reporting_events_on_created_at" t.index ["created_at"], name: "index_reporting_events_on_created_at"
t.index ["inbox_id"], name: "index_reporting_events_on_inbox_id" t.index ["inbox_id"], name: "index_reporting_events_on_inbox_id"

View file

@ -17,6 +17,21 @@ describe ReportingEventListener do
listener.conversation_resolved(event) listener.conversation_resolved(event)
expect(account.reporting_events.where(name: 'conversation_resolved').count).to be 1 expect(account.reporting_events.where(name: 'conversation_resolved').count).to be 1
end end
context 'when business hours enabled for inbox' do
let(:created_at) { Time.zone.parse('March 20, 2022 00:00') }
let(:updated_at) { Time.zone.parse('March 26, 2022 23:59') }
let!(:new_inbox) { create(:inbox, working_hours_enabled: true, account: account) }
let!(:new_conversation) do
create(:conversation, created_at: created_at, updated_at: updated_at, account: account, inbox: new_inbox, assignee: user)
end
it 'creates conversation_resolved event with business hour value' do
event = Events::Base.new('conversation.resolved', Time.zone.now, conversation: new_conversation)
listener.conversation_resolved(event)
expect(account.reporting_events.where(name: 'conversation_resolved')[0]['value_in_business_hours']).to be 144_000.0
end
end
end end
describe '#first_reply_created' do describe '#first_reply_created' do
@ -26,5 +41,24 @@ describe ReportingEventListener do
listener.first_reply_created(event) listener.first_reply_created(event)
expect(account.reporting_events.where(name: 'first_response').count).to eql previous_count + 1 expect(account.reporting_events.where(name: 'first_response').count).to eql previous_count + 1
end end
context 'when business hours enabled for inbox' do
let(:conversation_created_at) { Time.zone.parse('March 20, 2022 00:00') }
let(:message_created_at) { Time.zone.parse('March 26, 2022 23:59') }
let!(:new_inbox) { create(:inbox, working_hours_enabled: true, account: account) }
let!(:new_conversation) do
create(:conversation, created_at: conversation_created_at, account: account, inbox: new_inbox, assignee: user)
end
let!(:new_message) do
create(:message, message_type: 'outgoing', created_at: message_created_at,
account: account, inbox: new_inbox, conversation: new_conversation)
end
it 'creates first_response event with business hour value' do
event = Events::Base.new('first.reply.created', Time.zone.now, message: new_message)
listener.first_reply_created(event)
expect(account.reporting_events.where(name: 'first_response')[0]['value_in_business_hours']).to be 144_000.0
end
end
end end
end end