Files
redmine_contracts/app/models/deliverable.rb

392 lines
10 KiB
Ruby

class Deliverable < ActiveRecord::Base
unloadable
ViewPrecision = 2
# Associations
belongs_to :contract
belongs_to :manager, :class_name => 'User', :foreign_key => 'manager_id'
has_many :labor_budgets
has_many :overhead_budgets
has_many :fixed_budgets
has_many :issues, :dependent => :nullify
accepts_nested_attributes_for :labor_budgets, :allow_destroy => true
accepts_nested_attributes_for :overhead_budgets, :allow_destroy => true
accepts_nested_attributes_for :fixed_budgets, :allow_destroy => true
# Validations
validates_presence_of :title
validates_presence_of :type
validates_presence_of :manager
validates_inclusion_of :status, :in => ["open","locked","closed"], :allow_blank => true, :allow_nil => true
validate_on_update :validate_status_changes
validate :validate_contract_status
# Accessors
include DollarizedAttribute
dollarized_attribute :total
delegate :name, :to => :contract, :prefix => true, :allow_nil => true
delegate "open?", :to => :contract, :prefix => true, :allow_nil => true
delegate "closed?", :to => :contract, :prefix => true, :allow_nil => true
delegate "locked?", :to => :contract, :prefix => true, :allow_nil => true
delegate :project, :to => :contract, :allow_nil => true
# Callbacks
before_destroy :block_on_locked_contracts
before_destroy :block_on_closed_contracts
def after_initialize
self.status = "open" unless self.status.present?
end
# Register callbacks here, on new records the class isn't set so class-specific
# callbacks don't fire.
def after_save
if type == "RetainerDeliverable"
self.becomes(self.type.constantize).create_budgets_for_periods
end
end
named_scope :by_title, {:order => "#{Deliverable.table_name}.title ASC"}
named_scope :with_status, lambda {|statuses|
{
:conditions => ["#{Deliverable.table_name}.status IN (?)", statuses]
}
}
def short_type
''
end
def humanize_type
type.to_s.sub('Deliverable','')
end
# Deliverable's aren't dated. Subclasses may override this for period behavior.
def current_date
nil
end
def lock!
update_attribute(:status, "locked")
end
def close!
update_attribute(:status, "closed")
end
def open?
self.status == "open"
end
def locked?
self.status == "locked"
end
def closed?
self.status == "closed"
end
def editable?
(new_record? || open?)
end
def valid_status_change?
change_to_status_only? || changing_to_the_open_status? || changing_from_the_open_status?
end
def change_to_status_only?
["status"] == changes.keys
end
def changing_to_the_open_status?
changes["status"].present? && "open" == changes["status"].second
end
def changing_from_the_open_status?
changes["status"].present? && "open" == changes["status"].first
end
# TODO: duplicated on Contract, refactor after one more duplication
def validate_status_changes
return if valid_status_change?
errors.add_to_base(:cant_update_locked_deliverable) if locked?
errors.add_to_base(:cant_update_closed_deliverable) if closed?
end
def validate_contract_status
return if contract_open?
return if change_to_status_only?
if contract_locked?
if new_record?
errors.add_to_base(:cant_create_deliverable_on_locked_contract)
else
errors.add_to_base(:cant_update_locked_contract)
end
end
if contract_closed?
if new_record?
errors.add_to_base(:cant_create_deliverable_on_closed_contract)
else
errors.add_to_base(:cant_update_closed_contract)
end
end
end
# No operation method, useful to clean up logic with an optional message
# for documentation
def noop(message="")
end
def block_on_locked_contracts
!contract_locked?
end
def block_on_closed_contracts
!contract_closed?
end
def to_s
title
end
def to_underscore
self.class.to_s.underscore
end
def labor_budget_total(date=nil)
memoize_by_date("@labor_budget_total", date) do
labor_budgets.sum(:budget)
end
end
def overhead_budget_total(date=nil)
memoize_by_date("@overhead_budget_total", date) do
overhead_budgets.sum(:budget)
end
end
# The amount of profit that is budgeted for this deliverable.
# Profit = Total - ( Labor + Overhead + Fixed + Markup )
def profit_budget(date=nil)
memoize_by_date("@profit_budget", date) do
budgets = labor_budget_total(date) + overhead_budget_total(date) + fixed_budget_total(date) + fixed_markup_budget_total(date)
(total(date) || 0.0) - budgets
end
end
# The amount of money remaining after expenses have been taken out
# Profit left = Total - Labor spent - Overhead spent - Fixed - Markup
def profit_left(date=nil)
memoize_by_date("@profit_left", date) do
total_spent(date) - labor_budget_spent(date) - overhead_spent(date) - fixed_budget_total_spent(date) - fixed_markup_budget_total_spent(date)
end
end
def labor_budget_hours(date=nil)
memoize_by_date("@labor_budget_hours", date) do
labor_budgets.sum(:hours)
end
end
def overhead_budget_hours(date=nil)
memoize_by_date("@overhead_budget_hours", date) do
overhead_budgets.sum(:hours)
end
end
# Total number of hours estimated in the Deliverable's budgets
def estimated_hour_budget_total(date=nil)
memoize_by_date("@estimated_hour_budget_total", date) do
labor_budget_hours(date) + overhead_budget_hours(date)
end
end
# OPTIMIZE: N+1
def labor_hours_spent_total(date=nil)
memoize_by_date("@labor_hours_spent_total", date) do
issues.inject(0) {|total, issue| total += issue.billable_time_spent } # From redmine_overhead
end
end
# OPTIMIZE: N+1
def overhead_hours_spent_total(date=nil)
memoize_by_date("@overhead_hours_spent_total", date) do
issues.inject(0) {|total, issue| total += issue.overhead_time_spent } # From redmine_overhead
end
end
def hours_spent_total(date=nil)
return 0 if issues.empty?
# Don't count subissues
TimeEntry.sum(:hours, :conditions => { :issue_id => issues.collect(&:id) })
end
def fixed_budget_total(date=nil)
memoize_by_date("@fixed_budget_total", date) do
fixed_budgets.sum(:budget)
end
end
def fixed_budget_total_spent(date=nil)
memoize_by_date("@fixed_budget_total_spent", date) do
fixed_budgets.paid.sum(:budget)
end
end
# OPTIMIZE: N+1
def fixed_markup_budget_total(date=nil)
memoize_by_date("@fixed_markup_budget_total", date) do
fixed_budgets.inject(0) {|total, fixed_budget| total += fixed_budget.markup_value }
end
end
# OPTIMIZE: N+1
def fixed_markup_budget_total_spent(date=nil)
memoize_by_date("@fixed_markup_budget_total_spent", date) do
fixed_budgets.paid.inject(0) {|total, fixed_budget| total += fixed_budget.markup_value }
end
end
def filter_by_date(date=nil, &block)
block.call
end
def issues_by_status
issues.inject({}) {|grouped, issue|
grouped[issue.status] ||= []
grouped[issue.status] << issue
grouped
}
end
def retainer?
type == "RetainerDeliverable"
end
def billable_time_entry_activities
project.activities.select {|activity| activity.billable? }
end
# Total amount spent ($) for a given activity
def spent_for_activity(activity)
issues.all.inject(0.0) do |all_issues_total, issue|
all_issues_total += issue.time_entries.all(:conditions => {:activity_id => activity.id}).sum(&:cost)
all_issues_total
end
end
# Total hours spent for a given activity
def hours_spent_for_activity(activity)
issue_ids = issues.collect(&:id)
TimeEntry.sum(:hours,
:conditions => ["#{TimeEntry.table_name}.issue_id IN (?) AND activity_id IN (?)", issue_ids, activity.id])
end
# Total budget ($) for a given activity
def budget_for_activity(activity)
labor = labor_budgets.sum(:budget,
:conditions => ["#{LaborBudget.table_name}.time_entry_activity_id IN (?)", activity.id])
overhead = overhead_budgets.sum(:budget,
:conditions => ["#{OverheadBudget.table_name}.time_entry_activity_id IN (?)", activity.id])
labor.to_f + overhead.to_f
end
# Total budget (hours) a given activity
def hours_budget_for_activity(activity)
labor = labor_budgets.sum(:hours,
:conditions => ["#{LaborBudget.table_name}.time_entry_activity_id IN (?)", activity.id])
overhead = overhead_budgets.sum(:hours,
:conditions => ["#{OverheadBudget.table_name}.time_entry_activity_id IN (?)", activity.id])
labor.to_f + overhead.to_f
end
def self.valid_types
['FixedDeliverable','HourlyDeliverable','RetainerDeliverable']
end
def self.valid_types_to_select
valid_types.inject([]) do |types, type|
types << [type.gsub(/Deliverable/i,''), type]
types
end
end
# Required attribute for AAJ's JournalFormatter
def name
title
end
# Accessors from the budget plugin that need to be wrapped
def subject
warn "[DEPRECATION] Deliverable#subject is deprecated. Please use Deliverable#title instead."
title
end
def due
warn "[DEPRECATION] Deliverable#due is deprecated. Please use Deliverable#end_date instead."
end_date
end
def hours_used
warn "[DEPRECATION] Deliverable#hours_used is deprecated. Please use Deliverable#hours_spent_total instead."
hours_spent_total
end
def spent
warn "[DEPRECATION] Deliverable#spent is deprecated. Please use Deliverable#total_spent instead."
total_spent
end
def total_hours
warn "[DEPRECATION] Deliverable#total_hours is deprecated. Please use Deliverable#estimated_hour_budget_total instead."
estimated_hour_budget_total
end
def labor_budget
warn "[DEPRECATION] Deliverable#labor_budget is deprecated. Please use Deliverable#labor_budget_total instead."
labor_budget_total
end
if Rails.env.test?
generator_for :title, :method => :next_title
generator_for :status, 'open'
def self.next_title
@last_title ||= 'Deliverable 0000'
@last_title.succ!
end
end
private
def memoize_by_date(ivar, date, &block)
cache_hash = instance_variable_get(ivar)
cache_hash ||= {}
if date
if date.is_a?(Date)
cache_key = "#{date.year}-#{date.month}"
else
cache_key = :invalid
end
else
cache_key = :all
end
cache_hash[cache_key] ||= block.call
instance_variable_set(ivar, cache_hash)
cache_hash[cache_key]
end
end