373 lines
15 KiB
Ruby
373 lines
15 KiB
Ruby
# frozen_string_literal: true
|
||
|
||
##
|
||
# Provides methods for building the workload table. These methods are used in
|
||
# WorkloadsController and its views.
|
||
#
|
||
class UserWorkload
|
||
include Redmine::I18n
|
||
include WlIssueQuery
|
||
include WlIssueState
|
||
|
||
attr_reader :assignees, :issues, :time_span, :today
|
||
|
||
def initialize(assignees:, time_span:, today:, issues: nil)
|
||
self.assignees = assignees
|
||
self.issues = open_issues_for_users(assignees, issues)
|
||
self.time_span = time_span
|
||
self.today = today
|
||
end
|
||
|
||
##
|
||
# Returns the hours per day in the given time span (including firstDay and
|
||
# lastDay) for each open issue of each of the given users.
|
||
# The result is returned as nested hash:
|
||
# The topmost hash takes a user object as key and returns a hash that takes
|
||
# among others a project as key.
|
||
# The projects hash takes among others an issue as key which again
|
||
# returns the day related data in another hash as returned by
|
||
# UserWorkload#hours_for_issue_per_day.
|
||
#
|
||
# @example Returned hash for a two day time span
|
||
#
|
||
# { #<User id: 12, ...> => { :overdue_hours => 0.0,
|
||
# :overdue_number => 0,
|
||
# :total => { Sat, 12 Mar 2022 => { :hours=>0.0, :holiday=>true },
|
||
# Sun, 13 Mar 2022 => { :hours=>0.0, :holiday=>true } },
|
||
# :invisible => {},
|
||
# #<Project id: 4711, ...> =>
|
||
# { :total => { Sat, 12 Mar 2022=>{:hours=>0.0, :holiday=>true},
|
||
# Sun, 13 Mar 2022=>{:hours=>0.0, :holiday=>true} },
|
||
# :overdue_hours => 0.0,
|
||
# :overdue_number => 0,
|
||
# #<Issue id: 12176, ...> => { Sat, 12 Mar 2022 => { :hours => 0.0,
|
||
# :active => true,
|
||
# :noEstimate => false,
|
||
# :holiday => true },
|
||
# Sun, 13 Mar 2022 => { :hours => 0.0,
|
||
# :active => true,
|
||
# :noEstimate => false,
|
||
# :holiday => true } } } }
|
||
#
|
||
# Additionally, the returned hash has two special keys:
|
||
# * :invisible. Returns a summary of all issues that are not visible for the
|
||
# currently logged in user.
|
||
# ´* :total. Returns a summary of all issues for the user that this hash is
|
||
# for.
|
||
# @return [Hash] Hash with all relevant data for displaying the workload table
|
||
# on user base.
|
||
def hours_per_user_issue_and_day
|
||
raise ArgumentError unless issues.is_a?(Array)
|
||
raise ArgumentError unless time_span.is_a?(Range)
|
||
raise ArgumentError unless today.is_a?(Date)
|
||
|
||
result = {}
|
||
|
||
issues.group_by(&:assigned_to).each do |assignee, issue_set|
|
||
working_days = working_days_in_time_span(assignee: assignee)
|
||
first_working_day_from_today_on = working_days.select { |day| day >= today }.min || today
|
||
cap = WlDayCapacity.new(assignee: assignee)
|
||
|
||
assignee = GroupUserDummy.new(group: assignee) if assignee.is_a? Group
|
||
|
||
unless result.key?(assignee)
|
||
result[assignee] = {
|
||
overdue_hours: 0.0,
|
||
overdue_number: 0,
|
||
unscheduled_hours: 0.0,
|
||
unscheduled_number: 0,
|
||
total: {},
|
||
invisible: {}
|
||
}
|
||
|
||
time_span.each do |day|
|
||
holiday = working_days.exclude?(day)
|
||
result[assignee][:total][day] = {
|
||
hours: 0.0,
|
||
holiday: holiday,
|
||
lowload: threshold_at(cap, holiday, :lowload),
|
||
normalload: threshold_at(cap, holiday, :normalload),
|
||
highload: threshold_at(cap, holiday, :highload)
|
||
}
|
||
end
|
||
end
|
||
|
||
## Iterate over each issue in the array
|
||
issue_set.each do |issue|
|
||
project = issue.project
|
||
hours_for_issue = hours_for_issue_per_day(issue, cap, assignee)
|
||
remaining_estimated_hours = estimated_time_for_issue(issue)
|
||
|
||
# Add the issue to the total workload unless its overdue or unscheduled.
|
||
# @note issue_overdue? implies there is a due_date. In order to avoid
|
||
# double counting, a missing start_date will be ignored as criteria of
|
||
# beeing unscheduled.
|
||
if issue_overdue?(issue, today)
|
||
result[assignee][:overdue_hours] += hours_for_issue[first_working_day_from_today_on][:hours]
|
||
result[assignee][:overdue_number] += 1
|
||
elsif issue.due_date.nil?
|
||
result[assignee][:unscheduled_hours] += remaining_estimated_hours
|
||
result[assignee][:unscheduled_number] += 1
|
||
else
|
||
result[assignee][:total] = add_issue_info_to_summary(result[assignee][:total], hours_for_issue, assignee)
|
||
end
|
||
|
||
# If the issue is invisible, add it to the invisible issues summary.
|
||
# Otherwise, add it to the project (and its summary) to which it belongs
|
||
# to.
|
||
if issue.visible?
|
||
unless result[assignee].key?(project)
|
||
result[assignee][project] = {
|
||
total: {},
|
||
overdue_hours: 0.0,
|
||
overdue_number: 0,
|
||
unscheduled_hours: 0.0,
|
||
unscheduled_number: 0
|
||
}
|
||
|
||
time_span.each do |day|
|
||
holiday = working_days.exclude?(day)
|
||
result[assignee][project][:total][day] = {
|
||
hours: 0.0,
|
||
holiday: holiday,
|
||
lowload: threshold_at(cap, holiday, :lowload),
|
||
normalload: threshold_at(cap, holiday, :normalload),
|
||
highload: threshold_at(cap, holiday, :highload)
|
||
}
|
||
end
|
||
end
|
||
|
||
# Add the issue to the project workload summary unless its overdue or unscheduled.
|
||
# @note issue_overdue? implies there is a due_date. In order to avoid
|
||
# double counting, a missing start_date will be ignored as criteria of
|
||
# beeing unscheduled.
|
||
if issue_overdue?(issue, today)
|
||
result[assignee][project][:overdue_hours] += hours_for_issue[first_working_day_from_today_on][:hours]
|
||
result[assignee][project][:overdue_number] += 1
|
||
elsif issue.due_date.nil?
|
||
result[assignee][project][:unscheduled_hours] += remaining_estimated_hours
|
||
result[assignee][project][:unscheduled_number] += 1
|
||
else
|
||
result[assignee][project][:total] =
|
||
add_issue_info_to_summary(result[assignee][project][:total], hours_for_issue, assignee)
|
||
end
|
||
|
||
# Add it to the issues for that project in any case.
|
||
result[assignee][project][issue] = hours_for_issue
|
||
else
|
||
unless issue_overdue?(issue, today)
|
||
result[assignee][:invisible] =
|
||
add_issue_info_to_summary(result[assignee][:invisible], hours_for_issue, assignee)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
result
|
||
end
|
||
|
||
alias by_user hours_per_user_issue_and_day
|
||
|
||
private
|
||
|
||
attr_writer :assignees, :issues, :time_span, :today
|
||
|
||
##
|
||
# Returns the hours per day for the given issue. The result is only computed
|
||
# for days in the given time span. The function assumes that firstDay is
|
||
# today, so all remaining hours need to be done on or after firstDay.
|
||
# If the issue is overdue, all hours are assigned to the first working day
|
||
# after firstDay, or to firstDay itself, if it is a working day.
|
||
#
|
||
# The result is a hash taking a Date as key and returning a hash with the
|
||
# following keys:
|
||
# * :hours - the hours needed on that day
|
||
# * :active - true if the issue is active on that day, false else
|
||
# * :noEstimate - no estimated hours calculated because the issue has
|
||
# no estimate set or either start-time or end-time are not
|
||
# set.
|
||
# * :holiday - true if this is a holiday, false otherwise.
|
||
#
|
||
# @param issue [Issue] A single issue object.
|
||
# @param time_span [Range] Relevant time span.
|
||
# @param today [Date] The date of today.
|
||
#
|
||
# @return [Hash] If the given time span is empty, an empty hash is returned.
|
||
#
|
||
def hours_for_issue_per_day(issue, cap, assignee)
|
||
raise ArgumentError unless issue.is_a?(Issue)
|
||
raise ArgumentError unless time_span.is_a?(Range)
|
||
raise ArgumentError unless today.is_a?(Date)
|
||
|
||
hours_remaining = estimated_time_for_issue(issue)
|
||
working_days = working_days_in_time_span(assignee: assignee)
|
||
|
||
result = {}
|
||
|
||
# If issue is overdue and the remaining time may be estimated, all
|
||
# remaining hours are put on first working day.
|
||
if !issue.due_date.nil? && (issue.due_date < today)
|
||
|
||
# Initialize all days to inactive
|
||
time_span.each do |day|
|
||
# A day is active if it is after the issue start and before the issue due date
|
||
is_active = (day <= issue.due_date && (issue.start_date.nil? || issue.start_date >= day))
|
||
holiday = working_days.exclude?(day)
|
||
|
||
result[day] = {
|
||
hours: 0.0,
|
||
active: is_active,
|
||
noEstimate: false,
|
||
holiday: holiday,
|
||
lowload: threshold_at(cap, holiday, :lowload),
|
||
normalload: threshold_at(cap, holiday, :normalload),
|
||
highload: threshold_at(cap, holiday, :highload)
|
||
}
|
||
end
|
||
|
||
first_working_day_after_today = WlDateTools.working_days_in_time_span(today..time_span.end, assignee).min
|
||
result[first_working_day_after_today] = {} if result[first_working_day_after_today].nil?
|
||
result[first_working_day_after_today][:hours] = hours_remaining
|
||
|
||
# If the hours needed for an issue can not be estimated, set all days
|
||
# outside the issues time to inactive, and all days within the issues time
|
||
# to active but not estimated.
|
||
elsif issue.due_date.nil? || issue.start_date.nil?
|
||
time_span.each do |day|
|
||
holiday = working_days.exclude?(day)
|
||
|
||
# Check: Is the issue is active on day?
|
||
result[day] = if (!issue.due_date.nil? && (day <= issue.due_date)) ||
|
||
(!issue.start_date.nil? && (day >= issue.start_date)) ||
|
||
(issue.start_date.nil? && issue.due_date.nil?)
|
||
|
||
{
|
||
hours: 0.0, # No estimate possible, use zero
|
||
# to make other calculations easy.
|
||
active: true,
|
||
noEstimate: true && !holiday, # On holidays, the zero hours
|
||
# are *not* estimated
|
||
holiday: holiday,
|
||
lowload: threshold_at(cap, holiday, :lowload),
|
||
normalload: threshold_at(cap, holiday, :normalload),
|
||
highload: threshold_at(cap, holiday, :highload)
|
||
}
|
||
|
||
# Issue is not active
|
||
else
|
||
{
|
||
hours: 0.0, # Not active => 0 hours to do.
|
||
active: false,
|
||
noEstimate: false,
|
||
holiday: holiday,
|
||
lowload: threshold_at(cap, holiday, :lowload),
|
||
normalload: threshold_at(cap, holiday, :normalload),
|
||
highload: threshold_at(cap, holiday, :highload)
|
||
}
|
||
end
|
||
end
|
||
|
||
# The issue has start and end date
|
||
else
|
||
# Number of remaining working days for the issue:
|
||
remaining_time_span = [today, issue.start_date].max..issue.due_date
|
||
number_of_workdays_for_issue = WlDateTools.real_distance_in_days(remaining_time_span, assignee)
|
||
hours_per_workday = hours_remaining / number_of_workdays_for_issue.to_f
|
||
|
||
time_span.each do |day|
|
||
holiday = working_days.exclude?(day)
|
||
|
||
result[day] = if (day >= issue.start_date) && (day <= issue.due_date)
|
||
|
||
if day >= today
|
||
hours = holiday ? 0.0 : hours_per_workday
|
||
{
|
||
hours: hours,
|
||
active: true,
|
||
noEstimate: issue.estimated_hours.nil? && !holiday,
|
||
holiday: holiday,
|
||
lowload: threshold_at(cap, holiday, :lowload),
|
||
normalload: threshold_at(cap, holiday, :normalload),
|
||
highload: threshold_at(cap, holiday, :highload)
|
||
}
|
||
else
|
||
{
|
||
hours: 0.0,
|
||
active: true,
|
||
noEstimate: false,
|
||
holiday: holiday,
|
||
lowload: threshold_at(cap, holiday, :lowload),
|
||
normalload: threshold_at(cap, holiday, :normalload),
|
||
highload: threshold_at(cap, holiday, :highload)
|
||
}
|
||
end
|
||
else
|
||
{
|
||
hours: 0.0,
|
||
active: false,
|
||
noEstimate: false,
|
||
holiday: holiday,
|
||
lowload: threshold_at(cap, holiday, :lowload),
|
||
normalload: threshold_at(cap, holiday, :normalload),
|
||
highload: threshold_at(cap, holiday, :highload)
|
||
}
|
||
end
|
||
end
|
||
end
|
||
|
||
result
|
||
end
|
||
|
||
##
|
||
# Calculates the issues estimated hours weighted by its unfinished ratio.
|
||
#
|
||
# @param issue [Issue] The issue object with relevant estimated hours.
|
||
# @return [Float] The decimal number of remaining working hours.
|
||
#
|
||
#
|
||
def estimated_time_for_issue(issue)
|
||
raise ArgumentError unless issue.is_a?(Issue)
|
||
|
||
return 0.0 if issue.estimated_hours.nil?
|
||
return 0.0 if issue.children.any? && !consider_parent_issues?
|
||
|
||
issue.estimated_hours * ((100.0 - issue.done_ratio) / 100.0)
|
||
end
|
||
|
||
##
|
||
# Prepares a summary of issue infos.
|
||
#
|
||
# @param summary
|
||
# @param issue_info
|
||
#
|
||
def add_issue_info_to_summary(summary, issue_info, assignee)
|
||
summary ||= {}
|
||
|
||
time_span.each do |day|
|
||
holiday = { hours: 0.0, holiday: working_days_in_time_span(assignee: assignee).exclude?(day) }
|
||
summary[day] = holiday unless summary.key?(day)
|
||
summary[day][:hours] += issue_info[day][:hours]
|
||
end
|
||
|
||
summary
|
||
end
|
||
|
||
##
|
||
# Collects all working days within a given time span.
|
||
#
|
||
def working_days_in_time_span(assignee:, no_cache: false)
|
||
WlDateTools.working_days_in_time_span(time_span, assignee, no_cache: no_cache)
|
||
end
|
||
|
||
##
|
||
# Calculates the day and user dependent threshold value of the workload.
|
||
#
|
||
# @param cap [WlDayCapacity] An object able to calculate the workload day capacity.
|
||
# @param holiday [Boolean] Either a true or false value.
|
||
# @param key [Symbol|String] The short form of the threshold: lowload, normalload, highload.
|
||
#
|
||
def threshold_at(cap, holiday, key)
|
||
cap.threshold_at(key, holiday)
|
||
end
|
||
end
|