Include autenticazione, progetti isolati, mail marketing HTML con SMTP, test A/B e editor WYSIWYG. Co-authored-by: Cursor <cursoragent@cursor.com>
168 lines
5.0 KiB
Ruby
168 lines
5.0 KiB
Ruby
class Dashboard::Metrics
|
|
def initialize(scope: Opportunity.all, project: nil)
|
|
@scope = scope
|
|
@project = project
|
|
end
|
|
|
|
def stage_counts
|
|
@stage_counts ||= Catalog::PIPELINE_ORDER.index_with { |stage| @scope.where(pipeline_stage: stage).count }
|
|
end
|
|
|
|
def prospect_count
|
|
organizations_scope.prospects.count
|
|
end
|
|
|
|
def open_pipeline_value
|
|
@scope.open_stage.sum(:estimated_value).to_f
|
|
end
|
|
|
|
def won_value
|
|
@scope.won.sum(:estimated_value).to_f
|
|
end
|
|
|
|
def conversion_rates
|
|
stages = Catalog::OPEN_PIPELINE_STAGES + %w[won]
|
|
rates = {}
|
|
stages.each_cons(2) do |from, to|
|
|
from_count = cumulative_reached(from)
|
|
to_count = cumulative_reached(to)
|
|
rates["#{from}_to_#{to}"] = percentage(to_count, from_count)
|
|
end
|
|
rates
|
|
end
|
|
|
|
def funnel_steps
|
|
counts = {
|
|
"contacted" => reached_or_beyond("contacted"),
|
|
"replied" => reached_or_beyond("replied"),
|
|
"interested" => reached_or_beyond("interested"),
|
|
"first_use" => reached_or_beyond("first_use"),
|
|
"won" => @scope.won.count
|
|
}
|
|
|
|
steps = []
|
|
previous = nil
|
|
counts.each do |stage, count|
|
|
rate = previous ? percentage(count, previous) : nil
|
|
steps << { stage: stage, label: Catalog.label_for(Catalog::PIPELINE_STAGES, stage), count: count, rate: rate }
|
|
previous = count
|
|
end
|
|
steps
|
|
end
|
|
|
|
def avg_days_to_won
|
|
records = @scope.won.where.not(first_contacted_at: nil, won_at: nil)
|
|
return nil if records.empty?
|
|
|
|
total = records.sum { |o| ((o.won_at - o.first_contacted_at) / 1.day) }
|
|
(total / records.size).round(1)
|
|
end
|
|
|
|
def avg_days_since_last_activity
|
|
orgs = organizations_scope.left_joins(:activities)
|
|
.select("organizations.id, MAX(activities.happened_at) AS last_at")
|
|
.group("organizations.id")
|
|
.having("MAX(activities.happened_at) IS NOT NULL")
|
|
return nil if orgs.empty?
|
|
|
|
days = orgs.map { |o| ((Time.current - o.last_at) / 1.day) }
|
|
(days.sum / days.size).round(1)
|
|
end
|
|
|
|
def prospects_without_activity_over_7_days
|
|
organizations_scope.prospects
|
|
.left_joins(:activities)
|
|
.group("organizations.id")
|
|
.having("MAX(activities.happened_at) IS NULL OR MAX(activities.happened_at) < ?", 7.days.ago)
|
|
.count
|
|
.size
|
|
end
|
|
|
|
def overdue_tasks_count
|
|
tasks_scope.overdue.count
|
|
end
|
|
|
|
def opportunities_without_next_action
|
|
opportunities_missing_next_action.count
|
|
end
|
|
|
|
def demo_to_won_conversion
|
|
demo = reached_or_beyond("demo_trial")
|
|
percentage(@scope.won.count, demo)
|
|
end
|
|
|
|
def first_use_to_won_conversion
|
|
first_use = reached_or_beyond("first_use")
|
|
percentage(@scope.won.count, first_use)
|
|
end
|
|
|
|
def arpa
|
|
won = @scope.won.where.not(estimated_value: nil)
|
|
return 0 if won.empty?
|
|
|
|
(won.sum(:estimated_value).to_f / won.count).round(2)
|
|
end
|
|
|
|
def attention_items
|
|
{
|
|
to_send_count: @scope.where(send_status: "to_send").count,
|
|
interested_without_followup: interested_without_followup,
|
|
stalled_opportunities: stalled_opportunities,
|
|
overdue_tasks: tasks_scope.overdue.includes(:organization, :contact, :assigned_user).ordered.limit(20),
|
|
organizations_without_contacts: organizations_scope.left_joins(:contacts).where(contacts: { id: nil }).limit(20),
|
|
opportunities_without_value: advanced_open_stage.where(estimated_value: [nil, 0]).includes(:organization).limit(20),
|
|
opportunities_without_next_action: opportunities_missing_next_action.merge(advanced_open_stage).limit(20)
|
|
}
|
|
end
|
|
|
|
private
|
|
|
|
def organizations_scope
|
|
@project ? Organization.for_project(@project) : Organization.all
|
|
end
|
|
|
|
def tasks_scope
|
|
@project ? Task.for_project(@project) : Task.all
|
|
end
|
|
|
|
def cumulative_reached(stage)
|
|
reached_or_beyond(stage)
|
|
end
|
|
|
|
def reached_or_beyond(stage)
|
|
idx = Catalog::PIPELINE_ORDER.index(stage)
|
|
return 0 unless idx
|
|
|
|
stages = Catalog::PIPELINE_ORDER[idx..] - ["lost"]
|
|
@scope.where(pipeline_stage: stages).count
|
|
end
|
|
|
|
def percentage(part, whole)
|
|
return 0 if whole.to_i.zero?
|
|
|
|
((part.to_f / whole) * 100).round
|
|
end
|
|
|
|
def interested_without_followup
|
|
base = @scope.where(pipeline_stage: "interested")
|
|
with_pending = Task.pending.where.not(opportunity_id: nil).select(:opportunity_id)
|
|
base.where.not(id: with_pending).includes(:organization).limit(20)
|
|
end
|
|
|
|
def stalled_opportunities
|
|
@scope.open_stage
|
|
.where("stage_changed_at < ? OR (stage_changed_at IS NULL AND opportunities.created_at < ?)", 7.days.ago, 7.days.ago)
|
|
.includes(:organization, :assigned_user)
|
|
.limit(20)
|
|
end
|
|
|
|
def opportunities_missing_next_action
|
|
with_pending = Task.pending.where.not(opportunity_id: nil).select(:opportunity_id)
|
|
@scope.open_stage.where.not(id: with_pending).includes(:organization)
|
|
end
|
|
|
|
def advanced_open_stage
|
|
@scope.open_stage.where.not(pipeline_stage: "to_contact")
|
|
end
|
|
end
|