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 'html2text'
# to calculate working hours
gem 'working_hours'
group :production, :staging do
# we dont want request timing out in development while using byebug
gem 'rack-timeout'

View file

@ -636,6 +636,9 @@ GEM
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
wisper (2.0.0)
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.5.4)
PLATFORMS
@ -746,6 +749,7 @@ DEPENDENCIES
webpacker (~> 5.x)
webpush
wisper (= 2.0.0)
working_hours
RUBY VERSION
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?
end
def current_summary_params
def common_params
{
type: params[:type].to_sym,
id: params[:id],
since: range[:current][:since],
until: range[:current][:until],
group_by: params[:group_by]
group_by: params[:group_by],
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
}
end
def current_summary_params
common_params.merge({
since: range[:current][:since],
until: range[:current][:until]
})
end
def previous_summary_params
{
type: params[:type].to_sym,
id: params[:id],
since: range[:previous][:since],
until: range[:previous][:until],
group_by: params[:group_by]
}
common_params.merge({
since: range[:previous][:since],
until: range[:previous][:until]
})
end
def report_params
{
metric: params[:metric],
type: params[:type].to_sym,
since: params[:since],
until: params[:until],
id: params[:id],
group_by: params[:group_by],
timezone_offset: params[:timezone_offset]
}
common_params.merge({
metric: params[:metric],
since: params[:since],
until: params[:until],
timezone_offset: params[:timezone_offset]
})
end
def conversation_params

View file

@ -33,17 +33,23 @@ module ReportHelper
end
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
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
def avg_resolution_time_summary
avg_rt = scope.reporting_events
.where(name: 'conversation_resolved', created_at: range)
.average(:value)
reporting_events = scope.reporting_events
.where(name: 'conversation_resolved', created_at: range)
avg_rt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value)
return 0 if avg_rt.blank?
@ -51,9 +57,9 @@ module ReportHelper
end
def avg_first_response_time_summary
avg_frt = scope.reporting_events
.where(name: 'first_response', created_at: range)
.average(:value)
reporting_events = scope.reporting_events
.where(name: 'first_response', created_at: range)
avg_frt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value)
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' });
}
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}`, {
params: {
metric,
@ -17,12 +25,13 @@ class ReportsAPI extends ApiClient {
type,
id,
group_by,
business_hours,
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`, {
params: {
since,
@ -30,6 +39,7 @@ class ReportsAPI extends ApiClient {
type,
id,
group_by,
business_hours,
},
});
}

View file

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

View file

@ -25,3 +25,21 @@
align-items: center;
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": 3, "groupBy": "Month" },
{ "id": 4, "groupBy": "Year" }
]
],
"BUSINESS_HOURS": "Business Hours"
},
"AGENT_REPORTS": {
"HEADER": "Agents Overview",

View file

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

View file

@ -61,6 +61,12 @@
@input="handleAgentsFilterSelection"
/>
</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>
</template>
<script>
@ -105,6 +111,7 @@ export default {
customDateRange: [new Date(), new Date()],
currentSelectedFilter: null,
selectedAgents: [],
businessHoursSelected: false,
};
},
computed: {
@ -153,6 +160,9 @@ export default {
filterItemsList() {
this.currentSelectedFilter = this.selectedGroupByFilter;
},
businessHoursSelected() {
this.$emit('business-hours-toggle', this.businessHoursSelected);
},
},
mounted() {
this.onDateRangeChange();

View file

@ -145,6 +145,12 @@
@input="changeGroupByFilterSelection"
/>
</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>
</template>
<script>
@ -188,6 +194,7 @@ export default {
dateRange: this.$t('REPORT.DATE_RANGE'),
customDateRange: [new Date(), new Date()],
currentSelectedGroupByFilter: null,
businessHoursSelected: false,
};
},
computed: {
@ -249,6 +256,9 @@ export default {
groupByFilterItemsList() {
this.currentSelectedGroupByFilter = this.selectedGroupByFilter;
},
businessHoursSelected() {
this.$emit('business-hours-toggle', this.businessHoursSelected);
},
},
mounted() {
this.onDateRangeChange();

View file

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

View file

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

View file

@ -1,4 +1,5 @@
class ReportingEventListener < BaseListener
include ReportingEventHelper
def conversation_resolved(event)
conversation = extract_conversation_and_account(event)[0]
time_to_resolve = conversation.updated_at.to_i - conversation.created_at.to_i
@ -6,10 +7,14 @@ class ReportingEventListener < BaseListener
reporting_event = ReportingEvent.new(
name: 'conversation_resolved',
value: time_to_resolve,
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at,
conversation.updated_at),
account_id: conversation.account_id,
inbox_id: conversation.inbox_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
end
@ -22,10 +27,14 @@ class ReportingEventListener < BaseListener
reporting_event = ReportingEvent.new(
name: 'first_response',
value: first_response_time,
value_in_business_hours: business_hours(conversation.inbox, conversation.created_at,
message.created_at),
account_id: conversation.account_id,
inbox_id: conversation.inbox_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
end

View file

@ -2,15 +2,18 @@
#
# Table name: reporting_events
#
# id :bigint not null, primary key
# name :string
# value :float
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer
# conversation_id :integer
# inbox_id :integer
# user_id :integer
# id :bigint not null, primary key
# event_end_time :datetime
# event_start_time :datetime
# name :string
# value :float
# value_in_business_hours :float
# created_at :datetime not null
# updated_at :datetime not null
# account_id :integer
# conversation_id :integer
# inbox_id :integer
# user_id :integer
#
# 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.datetime "created_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 ["created_at"], name: "index_reporting_events_on_created_at"
t.index ["inbox_id"], name: "index_reporting_events_on_inbox_id"

View file

@ -17,6 +17,21 @@ describe ReportingEventListener do
listener.conversation_resolved(event)
expect(account.reporting_events.where(name: 'conversation_resolved').count).to be 1
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
describe '#first_reply_created' do
@ -26,5 +41,24 @@ describe ReportingEventListener do
listener.first_reply_created(event)
expect(account.reporting_events.where(name: 'first_response').count).to eql previous_count + 1
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