490 lines
15 KiB
Ruby
490 lines
15 KiB
Ruby
# A RetainerDeliverable is an HourlyDeliverable that is renewed at
|
|
# regular calendar periods. The Company bills a regular number of
|
|
# hours for a hourly rate whereby the budgets are reset over a
|
|
# regular cyclical period (monthly).
|
|
class RetainerDeliverable < HourlyDeliverable
|
|
unloadable
|
|
|
|
# Associations
|
|
|
|
# Validations
|
|
|
|
# Accessors
|
|
|
|
# Callbacks
|
|
before_update :check_for_extended_period
|
|
before_update :check_for_shrunk_period
|
|
|
|
def short_type
|
|
'R'
|
|
end
|
|
|
|
def current_date
|
|
Date.today
|
|
end
|
|
|
|
def current_period
|
|
current_date.strftime("%B %Y")
|
|
end
|
|
|
|
def beginning_date
|
|
start_date && start_date.beginning_of_month.to_date
|
|
end
|
|
|
|
def ending_date
|
|
end_date && end_date.end_of_month.to_date
|
|
end
|
|
|
|
def date_range
|
|
if beginning_date && ending_date && beginning_date <= ending_date
|
|
(beginning_date..ending_date)
|
|
else
|
|
[]
|
|
end
|
|
end
|
|
|
|
def within_date_range?(date)
|
|
date_range.include?(date)
|
|
end
|
|
|
|
# period in the format of "%Y-%m" or "%B %Y"
|
|
def within_period_range?(period)
|
|
begin
|
|
# both valid formats work by adding a day to the end like -01
|
|
date = Date.parse(period.to_s + "-01")
|
|
within_date_range?(date)
|
|
rescue ArgumentError
|
|
return false
|
|
end
|
|
end
|
|
|
|
def months
|
|
month_acc = []
|
|
|
|
current_date = beginning_date
|
|
return [] if current_date.nil? || ending_date.nil?
|
|
|
|
while current_date < ending_date do
|
|
month_acc << current_date
|
|
current_date = current_date.advance(:months => 1)
|
|
end
|
|
|
|
month_acc
|
|
end
|
|
|
|
# Returns the months used by the Deliverable that are before date
|
|
def months_before_date(date)
|
|
months.select {|m| m < date }
|
|
end
|
|
|
|
# Returns the months used by the Deliverable that are after date
|
|
def months_after_date(date)
|
|
months.select {|m| m > date }
|
|
end
|
|
|
|
def labor_budgets_for_date(date)
|
|
budgets = labor_budgets.all(:conditions => {:year => date.year, :month => date.month})
|
|
budgets = [labor_budgets.build(:year => date.year, :month => date.month)] if budgets.empty?
|
|
budgets
|
|
end
|
|
|
|
def overhead_budgets_for_date(date)
|
|
budgets = overhead_budgets.all(:conditions => {:year => date.year, :month => date.month})
|
|
budgets = [overhead_budgets.build(:year => date.year, :month => date.month)] if budgets.empty?
|
|
budgets
|
|
end
|
|
|
|
def fixed_budgets_for_date(date)
|
|
budgets = fixed_budgets.all(:conditions => {:year => date.year, :month => date.month})
|
|
budgets = [fixed_budgets.build(:year => date.year, :month => date.month)] if budgets.empty?
|
|
budgets
|
|
end
|
|
|
|
def labor_budget_total(date=nil)
|
|
case scope_date_status(date)
|
|
when :in
|
|
memoize_by_date("@labor_budget_total", date) do
|
|
labor_budgets.sum(:budget, :conditions => {:year => date.year, :month => date.month})
|
|
end
|
|
when :out
|
|
0
|
|
else
|
|
super
|
|
end
|
|
end
|
|
|
|
def overhead_budget_total(date=nil)
|
|
case scope_date_status(date)
|
|
when :in
|
|
memoize_by_date("@overhead_budget_total", date) do
|
|
overhead_budgets.sum(:budget, :conditions => {:year => date.year, :month => date.month})
|
|
end
|
|
when :out
|
|
0
|
|
else
|
|
super
|
|
end
|
|
end
|
|
|
|
def labor_budget_hours(date=nil)
|
|
case scope_date_status(date)
|
|
when :in
|
|
memoize_by_date("@labor_budget_hours", date) do
|
|
labor_budgets.sum(:hours, :conditions => {:year => date.year, :month => date.month})
|
|
end
|
|
when :out
|
|
0
|
|
else
|
|
super
|
|
end
|
|
end
|
|
|
|
def labor_hours_spent_total(date=nil)
|
|
case scope_date_status(date)
|
|
when :in
|
|
memoize_by_date("@labor_hours_spent_total", date) do
|
|
time_entries = issues.collect {|issue| issue.time_entries.all(:conditions => {:tyear => date.year, :tmonth => date.month}) }.flatten
|
|
|
|
billable_hours_on_time_entries(time_entries)
|
|
end
|
|
when :out
|
|
0
|
|
else
|
|
super
|
|
end
|
|
end
|
|
|
|
def overhead_hours_spent_total(date=nil)
|
|
case scope_date_status(date)
|
|
when :in
|
|
memoize_by_date("@overhead_hours_spent_total", date) do
|
|
time_entries = issues.collect {|issue| issue.time_entries.all(:conditions => {:tyear => date.year, :tmonth => date.month}) }.flatten
|
|
|
|
nonbillable_hours_on_time_entries(time_entries)
|
|
end
|
|
when :out
|
|
0
|
|
else
|
|
super
|
|
end
|
|
end
|
|
|
|
def hours_spent_total(date=nil)
|
|
case scope_date_status(date)
|
|
when :in
|
|
return 0 if issues.empty?
|
|
|
|
TimeEntry.sum(:hours, :conditions => {
|
|
:issue_id => issues.collect(&:id),
|
|
:tyear => date.year,
|
|
:tmonth => date.month
|
|
})
|
|
when :out
|
|
0
|
|
else
|
|
super
|
|
end
|
|
end
|
|
|
|
def fixed_budget_total(date=nil)
|
|
case scope_date_status(date)
|
|
when :in
|
|
memoize_by_date("@fixed_budget_total", date) do
|
|
fixed_budgets.sum(:budget, :conditions => {:year => date.year, :month => date.month})
|
|
end
|
|
when :out
|
|
0
|
|
else
|
|
super
|
|
end
|
|
end
|
|
|
|
def fixed_budget_total_spent(date=nil)
|
|
case scope_date_status(date)
|
|
when :in
|
|
memoize_by_date("@fixed_budget_total_spent", date) do
|
|
fixed_budgets.paid.sum(:budget, :conditions => {:year => date.year, :month => date.month})
|
|
end
|
|
when :out
|
|
0
|
|
else
|
|
super
|
|
end
|
|
end
|
|
|
|
def fixed_markup_budget_total(date=nil)
|
|
case scope_date_status(date)
|
|
when :in
|
|
memoize_by_date("@fixed_markup_budget_total", date) do
|
|
fixed_budgets.
|
|
all(:conditions => {:year => date.year, :month => date.month}).
|
|
inject(0) {|total, fixed_budget| total += fixed_budget.markup_value }
|
|
end
|
|
when :out
|
|
0
|
|
else
|
|
super
|
|
end
|
|
end
|
|
|
|
def fixed_markup_budget_total_spent(date=nil)
|
|
case scope_date_status(date)
|
|
when :in
|
|
memoize_by_date("@fixed_markup_budget_total_spent", date) do
|
|
fixed_budgets.
|
|
paid.
|
|
all(:conditions => {:year => date.year, :month => date.month}).
|
|
inject(0) {|total, fixed_budget| total += fixed_budget.markup_value }
|
|
end
|
|
when :out
|
|
0
|
|
else
|
|
super
|
|
end
|
|
end
|
|
|
|
def total_spent(date=nil)
|
|
case scope_date_status(date)
|
|
when :in
|
|
# TODO: duplicated on HourlyDeliverable#total_spent
|
|
memoize_by_date("@total_spent", date) do
|
|
return 0 if contract.nil?
|
|
return 0 if contract.billable_rate.blank?
|
|
return 0 unless self.issues.count > 0
|
|
|
|
issue_ids = self.issues.collect(&:id)
|
|
time_logs = time_entries_for_date_and_issue_ids(date, issue_ids)
|
|
|
|
hours = billable_hours_on_time_entries(time_logs)
|
|
|
|
fixed_budget_amount = fixed_budget_total_spent(date) + fixed_markup_budget_total_spent(date)
|
|
|
|
return (hours * contract.billable_rate) + fixed_budget_amount
|
|
end
|
|
when :out
|
|
0
|
|
else
|
|
super
|
|
end
|
|
|
|
end
|
|
|
|
# TODO: stolen directly from redmine_overhead but with a block option
|
|
def labor_budget_spent_with_filter(&block)
|
|
return 0.0 unless self.issues.size > 0
|
|
total = 0.0
|
|
|
|
# Get all timelogs assigned
|
|
if block_given?
|
|
time_logs = block.call
|
|
else
|
|
time_logs = self.issues.collect(&:time_entries).flatten
|
|
end
|
|
|
|
return time_logs.collect {|time_log|
|
|
if time_log.billable?
|
|
time_log.cost
|
|
else
|
|
0.0
|
|
end
|
|
}.sum
|
|
end
|
|
|
|
# TODO: stolen directly from redmine_overhead but with a block option
|
|
def overhead_spent_with_filter(&block)
|
|
if block_given?
|
|
time_logs = block.call
|
|
else
|
|
time_logs = issues.collect(&:time_entries).flatten
|
|
end
|
|
|
|
return time_logs.collect {|time_entry|
|
|
if time_entry.billable?
|
|
0
|
|
else
|
|
time_entry.cost
|
|
end
|
|
}.sum
|
|
end
|
|
|
|
def labor_budget_spent(date=nil)
|
|
case scope_date_status(date)
|
|
when :in
|
|
memoize_by_date("@labor_budget_spent", date) do
|
|
labor_budget_spent_with_filter do
|
|
issue_ids = self.issues.collect(&:id)
|
|
time_entries_for_date_and_issue_ids(date, issue_ids)
|
|
end
|
|
end
|
|
when :out
|
|
0
|
|
else
|
|
labor_budget_spent_with_filter
|
|
end
|
|
end
|
|
|
|
def overhead_spent(date=nil)
|
|
case scope_date_status(date)
|
|
when :in
|
|
memoize_by_date("@overhead_spent", date) do
|
|
overhead_spent_with_filter do
|
|
issue_ids = self.issues.collect(&:id)
|
|
time_entries_for_date_and_issue_ids(date, issue_ids)
|
|
end
|
|
end
|
|
when :out
|
|
0
|
|
else
|
|
overhead_spent_with_filter
|
|
end
|
|
end
|
|
|
|
def create_budgets_for_periods
|
|
# For each month in the time span
|
|
months.each do |month|
|
|
# Iterate over all un-dated budgets, created dated versions
|
|
undated_labor_budgets = labor_budgets.all(:conditions => ["#{LaborBudget.table_name}.year IS NULL AND #{LaborBudget.table_name}.month IS NULL"])
|
|
undated_labor_budgets.each do |template_budget|
|
|
labor_budgets.create(template_budget.attributes.merge(:year => month.year, :month => month.month))
|
|
end
|
|
|
|
undated_overhead_budgets = overhead_budgets.all(:conditions => ["#{OverheadBudget.table_name}.year IS NULL AND #{OverheadBudget.table_name}.month IS NULL"])
|
|
undated_overhead_budgets.each do |template_budget|
|
|
overhead_budgets.create(template_budget.attributes.merge(:year => month.year, :month => month.month))
|
|
end
|
|
|
|
undated_fixed_budgets = fixed_budgets.all(:conditions => ["#{FixedBudget.table_name}.year IS NULL AND #{FixedBudget.table_name}.month IS NULL"])
|
|
undated_fixed_budgets.each do |template_budget|
|
|
fixed_budgets.create(template_budget.attributes.merge(:year => month.year, :month => month.month))
|
|
end
|
|
end
|
|
# Destroy origional un-dated budgets
|
|
labor_budgets.all(:conditions => ["#{LaborBudget.table_name}.year IS NULL AND #{LaborBudget.table_name}.month IS NULL"]).collect(&:destroy)
|
|
overhead_budgets.all(:conditions => ["#{OverheadBudget.table_name}.year IS NULL AND #{OverheadBudget.table_name}.month IS NULL"]).collect(&:destroy)
|
|
fixed_budgets.all(:conditions => ["#{FixedBudget.table_name}.year IS NULL AND #{FixedBudget.table_name}.month IS NULL"]).collect(&:destroy)
|
|
end
|
|
|
|
def check_for_extended_period
|
|
# TODO: brute force. Alternative would be to check end_date_changes to see if the period actually shifted
|
|
if end_date_changed?
|
|
extend_period_to_new_end_date
|
|
end
|
|
|
|
# TODO: brute force. Alternative would be to check start_date_changes to see if the period actually shifted
|
|
if start_date_changed?
|
|
extend_period_to_new_start_date
|
|
end
|
|
end
|
|
|
|
def check_for_shrunk_period
|
|
if end_date_changed? || start_date_changed?
|
|
shrink_budgets_to_new_period
|
|
end
|
|
end
|
|
|
|
private
|
|
|
|
def shrink_budgets_to_new_period
|
|
return if beginning_date.nil? || ending_date.nil?
|
|
shrink_budgets(labor_budgets.all)
|
|
shrink_budgets(overhead_budgets.all)
|
|
shrink_budgets(fixed_budgets.all)
|
|
|
|
true
|
|
end
|
|
|
|
def shrink_budgets(budget_items)
|
|
budget_items.each do |budget_item|
|
|
# Purge un-dated budgets, should not be saved at all
|
|
budget_item.destroy unless budget_item.year.present?
|
|
budget_item.destroy unless budget_item.month.present?
|
|
|
|
# Purge budgets outside the new beginning/ending range
|
|
unless (beginning_date..ending_date).to_a.include?(Date.new(budget_item.year, budget_item.month, 1))
|
|
budget_item.destroy
|
|
end
|
|
end
|
|
end
|
|
|
|
def extend_period_to_new_end_date
|
|
return if end_date_change[0].nil? # No previous end date, so it will not have budgets
|
|
|
|
old_end_date = end_date_change[0]
|
|
last_labor_budgets = labor_budgets.all(:conditions => {:year => old_end_date.year, :month => old_end_date.month})
|
|
last_overhead_budgets = overhead_budgets.all(:conditions => {:year => old_end_date.year, :month => old_end_date.month})
|
|
last_fixed_budgets = fixed_budgets.all(:conditions => {:year => old_end_date.year, :month => old_end_date.month})
|
|
|
|
months_after_date(old_end_date.end_of_month.to_date).each do |new_period|
|
|
create_budgets_for_new_period(new_period, last_labor_budgets, last_overhead_budgets, last_fixed_budgets)
|
|
end
|
|
end
|
|
|
|
def extend_period_to_new_start_date
|
|
return if start_date_change[0].nil? # No previous start date, so it will not have budgets
|
|
|
|
old_start_date = start_date_change[0]
|
|
first_labor_budgets = labor_budgets.all(:conditions => {:year => old_start_date.year, :month => old_start_date.month})
|
|
first_overhead_budgets = overhead_budgets.all(:conditions => {:year => old_start_date.year, :month => old_start_date.month})
|
|
first_fixed_budgets = fixed_budgets.all(:conditions => {:year => old_start_date.year, :month => old_start_date.month})
|
|
|
|
months_before_date(old_start_date.beginning_of_month.to_date).each do |new_period|
|
|
create_budgets_for_new_period(new_period, first_labor_budgets, first_overhead_budgets, first_fixed_budgets)
|
|
end
|
|
|
|
end
|
|
|
|
def create_budgets_for_new_period(new_period, labor_budgets_to_copy, overhead_budgets_to_copy, fixed_budgets_to_copy)
|
|
labor_budgets_to_copy.each do |labor_budget_to_copy|
|
|
create_new_labor_budget_based_on_existing_budget(labor_budget_to_copy, 'year' => new_period.year, 'month' => new_period.month)
|
|
end
|
|
|
|
overhead_budgets_to_copy.each do |overhead_budget_to_copy|
|
|
create_new_overhead_budget_based_on_existing_budget(overhead_budget_to_copy, 'year' => new_period.year, 'month' => new_period.month)
|
|
end
|
|
|
|
fixed_budgets_to_copy.each do |fixed_budget_to_copy|
|
|
create_new_fixed_budget_based_on_existing_budget(fixed_budget_to_copy, 'year' => new_period.year, 'month' => new_period.month)
|
|
end
|
|
end
|
|
|
|
def create_new_labor_budget_based_on_existing_budget(existing_labor_budget, attributes={})
|
|
labor_budgets.create(existing_labor_budget.attributes.except('id').merge(attributes))
|
|
end
|
|
|
|
def create_new_overhead_budget_based_on_existing_budget(existing_overhead_budget, attributes={})
|
|
overhead_budgets.create(existing_overhead_budget.attributes.except('id').merge(attributes))
|
|
end
|
|
|
|
def create_new_fixed_budget_based_on_existing_budget(existing_fixed_budget, attributes={})
|
|
fixed_budgets.create(existing_fixed_budget.attributes.except('id').merge(attributes))
|
|
end
|
|
|
|
def scope_date_status(date)
|
|
if date
|
|
if within_date_range?(date)
|
|
status = :in
|
|
else
|
|
status = :out # outside of range
|
|
end
|
|
else
|
|
status = :no_date
|
|
end
|
|
|
|
status
|
|
end
|
|
|
|
def time_entries_for_date_and_issue_ids(date, issue_ids)
|
|
if issue_ids.present?
|
|
TimeEntry.all(:conditions => ["#{Issue.table_name}.id IN (:issue_ids) AND tyear = (:year) AND tmonth = (:month)",
|
|
{:issue_ids => issue_ids,
|
|
:year => date.year,
|
|
:month => date.month}
|
|
],
|
|
:include => :issue)
|
|
else
|
|
[]
|
|
end
|
|
end
|
|
|
|
end
|