Files
eminuxCRM/app/services/dashboard/metrics.rb
T
eminuxandCursor c4e5f289cf
CI / scan_ruby (push) Failing after 11m20s
CI / scan_js (push) Successful in 10m35s
CI / lint (push) Has been cancelled
Commit iniziale di eminuxCRM: CRM Rails con pipeline, campagne email e Docker.
Include autenticazione, progetti isolati, mail marketing HTML con SMTP, test A/B e editor WYSIWYG.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 23:01:48 +02:00

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