Il funnel mostra stage attuale e cumulato, i KPI escludono Da contattare e le liste usano il totale reale invece del tetto a 20. Co-authored-by: Cursor <cursoragent@cursor.com>
202 lines
5.7 KiB
Ruby
202 lines
5.7 KiB
Ruby
class Dashboard::Metrics
|
|
FUNNEL_STAGES = (Catalog::OPEN_PIPELINE_STAGES + %w[won]).freeze
|
|
ATTENTION_PREVIEW = 8
|
|
|
|
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 = FUNNEL_STAGES
|
|
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
|
|
previous_reached = nil
|
|
previous_label = nil
|
|
|
|
FUNNEL_STAGES.map do |stage|
|
|
current = stage_counts[stage].to_i
|
|
reached = reached_or_beyond(stage)
|
|
label = Catalog.label_for(Catalog::PIPELINE_STAGES, stage)
|
|
step = {
|
|
stage: stage,
|
|
label: label,
|
|
current: current,
|
|
reached: reached
|
|
}
|
|
|
|
if previous_reached&.positive?
|
|
step[:rate] = percentage(reached, previous_reached)
|
|
step[:conversion_label] = "#{reached} su #{previous_reached} hanno superato #{previous_label}"
|
|
end
|
|
|
|
previous_reached = reached
|
|
previous_label = label
|
|
step
|
|
end
|
|
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
|
|
in_trattativa_without_next_action.count
|
|
end
|
|
|
|
def demo_to_won_conversion
|
|
stage_to_won_conversion("demo_trial")
|
|
end
|
|
|
|
def first_use_to_won_conversion
|
|
stage_to_won_conversion("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
|
|
overdue = tasks_scope.overdue.includes(:organization, :contact, :assigned_user).ordered
|
|
|
|
{
|
|
to_send_count: @scope.where(send_status: "to_send").count,
|
|
interested_without_followup: preview_attention(interested_without_followup_scope.includes(:organization)),
|
|
stalled_opportunities: preview_attention(stalled_opportunities_scope),
|
|
overdue_tasks: preview_attention(overdue),
|
|
organizations_without_contacts: preview_attention(organizations_without_contacts_scope),
|
|
opportunities_without_value: preview_attention(opportunities_without_value_scope),
|
|
opportunities_without_next_action: preview_attention(in_trattativa_without_next_action.includes(:organization))
|
|
}
|
|
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..].without("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 stage_to_won_conversion(stage)
|
|
return nil if stage_counts[stage].to_i.zero? && @scope.won.count.zero?
|
|
|
|
reached = reached_or_beyond(stage)
|
|
return nil if reached.zero?
|
|
|
|
percentage(@scope.won.count, reached)
|
|
end
|
|
|
|
def interested_without_followup_scope
|
|
@scope.where(pipeline_stage: "interested").where.not(id: pending_opportunity_ids)
|
|
end
|
|
|
|
def stalled_opportunities_scope
|
|
@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)
|
|
end
|
|
|
|
def organizations_without_contacts_scope
|
|
organizations_scope.left_joins(:contacts).where(contacts: { id: nil })
|
|
end
|
|
|
|
def opportunities_without_value_scope
|
|
advanced_open_stage.where("estimated_value IS NULL OR estimated_value = 0").includes(:organization)
|
|
end
|
|
|
|
def in_trattativa_without_next_action
|
|
advanced_open_stage.where.not(id: pending_opportunity_ids)
|
|
end
|
|
|
|
def pending_opportunity_ids
|
|
Task.pending.where.not(opportunity_id: nil).select(:opportunity_id)
|
|
end
|
|
|
|
def advanced_open_stage
|
|
@scope.open_stage.where.not(pipeline_stage: "to_contact")
|
|
end
|
|
|
|
def preview_attention(relation)
|
|
total = relation.unscope(:includes, :preload, :eager_load, :order, :limit).count
|
|
total = total.size if total.is_a?(Hash)
|
|
|
|
{ items: relation.limit(ATTENTION_PREVIEW).to_a, total: total }
|
|
end
|
|
end
|