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>
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
require "csv"
|
||||
|
||||
class CampaignImport::MatchlivetvLaunch
|
||||
CAMPAIGN_NAME = "Campagna lancio 01".freeze
|
||||
SPORT = "Pallavolo".freeze
|
||||
|
||||
Result = Struct.new(:wiped_organizations, :imported, :errors, keyword_init: true)
|
||||
|
||||
STREAMING_MAP = {
|
||||
"NON RILEVATO" => "not_detected",
|
||||
"LIMITATO" => "limited",
|
||||
"SI / PARZIALE" => "yes_partial",
|
||||
"SI" => "yes",
|
||||
"SI - SPORTCAM" => "yes_sportcam",
|
||||
"SI SPORTCAM" => "yes_sportcam"
|
||||
}.freeze
|
||||
|
||||
GENDER_MAP = {
|
||||
"F" => "female",
|
||||
"M" => "male",
|
||||
"M/F" => "mixed"
|
||||
}.freeze
|
||||
|
||||
SEND_MAP = {
|
||||
"DA INVIARE" => "to_send",
|
||||
"INVIATO" => "sent",
|
||||
"NON INVIARE" => "no_send"
|
||||
}.freeze
|
||||
|
||||
def initialize(path:, user: nil, project: nil, wipe: true)
|
||||
@path = Pathname.new(path)
|
||||
@user = user
|
||||
@project = project
|
||||
@wipe = wipe
|
||||
end
|
||||
|
||||
def call
|
||||
raise ArgumentError, "File non trovato: #{@path}" unless @path.exist?
|
||||
|
||||
@project ||= Project.find_by!(code: "matchlivetv")
|
||||
@user ||= User.find_by!(role: "admin")
|
||||
Current.user = @user
|
||||
Current.project = @project
|
||||
|
||||
wiped = @wipe ? wipe_project! : 0
|
||||
imported = 0
|
||||
errors = []
|
||||
|
||||
rows.each_with_index do |row, idx|
|
||||
import_row!(row)
|
||||
imported += 1
|
||||
rescue StandardError => e
|
||||
errors << { line: idx + 2, name: row["Società"], message: e.message }
|
||||
end
|
||||
|
||||
Result.new(wiped_organizations: wiped, imported: imported, errors: errors)
|
||||
ensure
|
||||
Current.user = nil
|
||||
Current.project = nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def rows
|
||||
CSV.read(@path, headers: true, encoding: "bom|utf-8")
|
||||
end
|
||||
|
||||
def wipe_project!
|
||||
destroyed = 0
|
||||
Organization.transaction do
|
||||
Opportunity.where(project_id: @project.id).find_each(&:destroy!)
|
||||
org_ids = OrganizationProject.where(project_id: @project.id).pluck(:organization_id)
|
||||
OrganizationProject.where(project_id: @project.id).delete_all
|
||||
Organization.where(id: org_ids).find_each do |org|
|
||||
next if org.organization_projects.exists?
|
||||
|
||||
org.destroy!
|
||||
destroyed += 1
|
||||
end
|
||||
end
|
||||
destroyed
|
||||
end
|
||||
|
||||
def import_row!(row)
|
||||
name = cell(row, "Società")
|
||||
raise "Società vuota" if name.blank?
|
||||
|
||||
org = Organization.new(
|
||||
name: name,
|
||||
organization_type: "societa_sportiva",
|
||||
sport: SPORT,
|
||||
country: "Italia",
|
||||
region: cell(row, "Regione"),
|
||||
province: cell(row, "Prov."),
|
||||
email: cell(row, "Email verificata")&.downcase,
|
||||
website: cell(row, "Sito / profilo"),
|
||||
source_url: cell(row, "Fonte contatto / ricerca"),
|
||||
commercial_fit: cell(row, "Evidenza / fit commerciale"),
|
||||
team_gender: GENDER_MAP.fetch(normalize_key(cell(row, "M/F"))) { raise "M/F sconosciuto: #{row['M/F']}" },
|
||||
streaming_status: streaming_status_for(cell(row, "Streaming rilevato")),
|
||||
list_position: integer_cell(row, "N."),
|
||||
verified_at: parse_date(cell(row, "Data verifica")),
|
||||
status: yes?(cell(row, "Conversione")) ? "active_customer" : "prospect",
|
||||
lead_source: "campaign",
|
||||
assigned_user: @user,
|
||||
notes: cell(row, "Note follow-up")
|
||||
)
|
||||
org.projects = [@project]
|
||||
org.save!
|
||||
|
||||
first_name, last_name, role = contact_from(org.email, org.name)
|
||||
org.contacts.create!(
|
||||
first_name: first_name,
|
||||
last_name: last_name,
|
||||
role: role,
|
||||
email: org.email,
|
||||
preferred_contact_method: "email",
|
||||
primary_contact: true
|
||||
)
|
||||
|
||||
converted = yes?(cell(row, "Conversione"))
|
||||
demo = yes?(cell(row, "Demo / Trial"))
|
||||
stage = if converted
|
||||
"won"
|
||||
elsif demo
|
||||
"demo_trial"
|
||||
else
|
||||
"to_contact"
|
||||
end
|
||||
|
||||
org.opportunities.create!(
|
||||
name: CAMPAIGN_NAME,
|
||||
project: @project,
|
||||
pipeline_stage: stage,
|
||||
product: cell(row, "Piano"),
|
||||
assigned_user: @user,
|
||||
ab_variant: cell(row, "Test A/B").to_s.upcase.presence,
|
||||
send_status: SEND_MAP.fetch(normalize_key(cell(row, "Stato invio")), "to_send"),
|
||||
sent_on: parse_date(cell(row, "Data invio")),
|
||||
outcome: cell(row, "Esito"),
|
||||
demo_trial: demo,
|
||||
converted: converted,
|
||||
notes: [cell(row, "Evidenza / fit commerciale"), cell(row, "Note follow-up")].compact_blank.join("\n\n")
|
||||
)
|
||||
end
|
||||
|
||||
def cell(row, header)
|
||||
value = row[header].to_s.strip
|
||||
value.presence
|
||||
end
|
||||
|
||||
def integer_cell(row, header)
|
||||
raw = cell(row, header)
|
||||
return if raw.blank?
|
||||
|
||||
raw.to_i
|
||||
end
|
||||
|
||||
def normalize_key(value)
|
||||
I18n.transliterate(value.to_s)
|
||||
.gsub(/[\u2013\u2014\u2212]/, "-")
|
||||
.encode("ASCII", invalid: :replace, undef: :replace, replace: "")
|
||||
.strip
|
||||
.upcase
|
||||
.gsub(/\s+/, " ")
|
||||
end
|
||||
|
||||
def streaming_status_for(value)
|
||||
key = normalize_key(value)
|
||||
return STREAMING_MAP[key] if STREAMING_MAP.key?(key)
|
||||
return "yes_sportcam" if key.include?("SPORTCAM")
|
||||
return "yes_partial" if key.include?("PARZIALE")
|
||||
return "not_detected" if key.include?("NON RILEVATO")
|
||||
return "limited" if key.include?("LIMITATO")
|
||||
return "yes" if key == "SI" || key.start_with?("SI ")
|
||||
|
||||
raise "Streaming sconosciuto: #{value}"
|
||||
end
|
||||
|
||||
def yes?(value)
|
||||
%w[SI YES TRUE 1].include?(normalize_key(value))
|
||||
end
|
||||
|
||||
def parse_date(value)
|
||||
return if value.blank?
|
||||
return Date.iso8601(value) if value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
||||
return Date.strptime(value, "%d/%m/%Y") if value.match?(/\A\d{1,2}\/\d{1,2}\/\d{4}\z/)
|
||||
|
||||
serial = Float(value)
|
||||
Date.new(1899, 12, 30) + serial.to_i
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
def contact_from(email, org_name)
|
||||
local = email.to_s.split("@").first.to_s
|
||||
if local.match?(/\A[a-z]+[._][a-z]+\z/i)
|
||||
first, last = local.split(/[._]/)
|
||||
[first.capitalize, last.capitalize, "Contatto"]
|
||||
elsif local.match?(/\A[a-z]\.[a-z]+\z/i)
|
||||
initial, last = local.split(".")
|
||||
["#{initial.upcase}.", last.capitalize, "Contatto"]
|
||||
else
|
||||
token = org_name.to_s.split(/[\s\/–-]+/).last.presence || "Società"
|
||||
["Segreteria", token, "Segreteria"]
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,42 @@
|
||||
require "csv"
|
||||
|
||||
class CsvExport
|
||||
def self.organizations(scope)
|
||||
CSV.generate(headers: true) do |csv|
|
||||
csv << %w[id list_position name team_gender streaming_status region province website email status lead_source commercial_fit source_url verified_at assigned_user notes created_at]
|
||||
scope.includes(:assigned_user).find_each do |org|
|
||||
csv << [
|
||||
org.id, org.list_position, org.name, org.team_gender, org.streaming_status, org.region,
|
||||
org.province, org.website, org.email, org.status, org.lead_source, org.commercial_fit,
|
||||
org.source_url, org.verified_at, org.assigned_user&.full_name, org.notes, org.created_at
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.contacts(scope)
|
||||
CSV.generate(headers: true) do |csv|
|
||||
csv << %w[id organization_name first_name last_name role email phone mobile preferred_contact_method primary_contact notes]
|
||||
scope.includes(:organization).find_each do |contact|
|
||||
csv << [
|
||||
contact.id, contact.organization.name, contact.first_name, contact.last_name, contact.role,
|
||||
contact.email, contact.phone, contact.mobile, contact.preferred_contact_method,
|
||||
contact.primary_contact, contact.notes
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.opportunities(scope)
|
||||
CSV.generate(headers: true) do |csv|
|
||||
csv << %w[id organization_name name pipeline_stage ab_variant send_status sent_on outcome demo_trial converted estimated_value product assigned_user notes]
|
||||
scope.includes(:organization, :assigned_user).find_each do |opp|
|
||||
csv << [
|
||||
opp.id, opp.organization.name, opp.name, opp.pipeline_stage, opp.ab_variant, opp.send_status,
|
||||
opp.sent_on, opp.outcome, opp.demo_trial, opp.converted, opp.estimated_value, opp.product,
|
||||
opp.assigned_user&.full_name, opp.notes
|
||||
]
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,127 @@
|
||||
require "csv"
|
||||
|
||||
class CsvImport::Organizations
|
||||
STANDARD_HEADERS = %w[
|
||||
organization_name organization_type sport country region province city website
|
||||
organization_email contact_first_name contact_last_name contact_role contact_email
|
||||
contact_phone lead_source notes
|
||||
].freeze
|
||||
|
||||
Result = Struct.new(:imported, :skipped, :errors, keyword_init: true)
|
||||
|
||||
def initialize(file:, user:, mapping: nil, project: nil)
|
||||
@file = file
|
||||
@user = user
|
||||
@mapping = mapping
|
||||
@project = project || Current.project
|
||||
end
|
||||
|
||||
def preview(limit: 10)
|
||||
rows = []
|
||||
CSV.foreach(@file.path, headers: true, encoding: "bom|utf-8") do |row|
|
||||
rows << row.to_h
|
||||
break if rows.size >= limit
|
||||
end
|
||||
{ headers: rows.first&.keys || [], rows: rows }
|
||||
end
|
||||
|
||||
def import!
|
||||
imported = 0
|
||||
skipped = 0
|
||||
errors = []
|
||||
|
||||
CSV.foreach(@file.path, headers: true, encoding: "bom|utf-8").with_index(2) do |row, line|
|
||||
Current.user = @user
|
||||
attrs = mapped_attrs(row)
|
||||
|
||||
if duplicate?(attrs)
|
||||
skipped += 1
|
||||
next
|
||||
end
|
||||
|
||||
Organization.transaction do
|
||||
org = Organization.new(
|
||||
name: attrs[:organization_name],
|
||||
organization_type: normalize_type(attrs[:organization_type]),
|
||||
sport: attrs[:sport],
|
||||
country: attrs[:country].presence || "Italia",
|
||||
region: attrs[:region],
|
||||
province: attrs[:province],
|
||||
city: attrs[:city],
|
||||
website: normalize_website(attrs[:website]),
|
||||
email: attrs[:organization_email],
|
||||
lead_source: normalize_lead_source(attrs[:lead_source]),
|
||||
notes: attrs[:notes],
|
||||
status: "prospect",
|
||||
assigned_user: @user
|
||||
)
|
||||
org.projects = [@project].compact
|
||||
org.save!
|
||||
|
||||
if attrs[:contact_first_name].present? || attrs[:contact_last_name].present?
|
||||
org.contacts.create!(
|
||||
first_name: attrs[:contact_first_name].presence || "N/D",
|
||||
last_name: attrs[:contact_last_name].presence || "N/D",
|
||||
role: attrs[:contact_role],
|
||||
email: attrs[:contact_email],
|
||||
phone: attrs[:contact_phone],
|
||||
primary_contact: true
|
||||
)
|
||||
end
|
||||
end
|
||||
imported += 1
|
||||
rescue StandardError => e
|
||||
errors << { line: line, message: e.message, row: row.to_h }
|
||||
end
|
||||
|
||||
Result.new(imported: imported, skipped: skipped, errors: errors)
|
||||
ensure
|
||||
Current.user = nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mapped_attrs(row)
|
||||
source = row.to_h.transform_keys { |k| k.to_s.strip }
|
||||
STANDARD_HEADERS.index_with do |header|
|
||||
key = @mapping&.dig(header) || header
|
||||
source[key].to_s.strip.presence
|
||||
end.symbolize_keys
|
||||
end
|
||||
|
||||
def duplicate?(attrs)
|
||||
name = attrs[:organization_name].to_s
|
||||
email = attrs[:organization_email].to_s.downcase
|
||||
website = normalize_website(attrs[:website])
|
||||
|
||||
return true if name.present? && Organization.where("LOWER(name) = ?", name.downcase).exists?
|
||||
return true if email.present? && Organization.where("LOWER(email) = ?", email).exists?
|
||||
return true if website.present? && Organization.where("LOWER(website) = ?", website.downcase).exists?
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def normalize_website(value)
|
||||
return if value.blank?
|
||||
|
||||
value.to_s.strip.downcase.sub(%r{\Ahttps?://}, "").sub(%r{/\z}, "")
|
||||
end
|
||||
|
||||
def normalize_type(value)
|
||||
return "altro" if value.blank?
|
||||
|
||||
key = value.to_s.downcase.gsub(/\s+/, "_")
|
||||
return key if Catalog::ORGANIZATION_TYPES.key?(key)
|
||||
|
||||
Catalog::ORGANIZATION_TYPES.find { |_k, label| label.downcase == value.to_s.downcase }&.first || "altro"
|
||||
end
|
||||
|
||||
def normalize_lead_source(value)
|
||||
return if value.blank?
|
||||
|
||||
key = value.to_s.downcase.gsub(/\s+/, "_")
|
||||
return key if Catalog::LEAD_SOURCES.key?(key)
|
||||
|
||||
Catalog::LEAD_SOURCES.find { |_k, label| label.downcase == value.to_s.downcase }&.first || "other"
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,167 @@
|
||||
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
|
||||
@@ -0,0 +1,62 @@
|
||||
module MailMerge
|
||||
TOKEN = /\{\{\s*([a-z0-9_.]+)\s*\}\}/i
|
||||
|
||||
class << self
|
||||
def catalog
|
||||
{
|
||||
"societa" => "Nome società",
|
||||
"regione" => "Regione",
|
||||
"provincia" => "Provincia",
|
||||
"citta" => "Città",
|
||||
"email" => "Email",
|
||||
"sito" => "Sito / profilo",
|
||||
"sport" => "Sport",
|
||||
"mf" => "M/F",
|
||||
"streaming" => "Streaming rilevato",
|
||||
"fit" => "Fit commerciale",
|
||||
"n_lista" => "N. in lista",
|
||||
"contatto_nome" => "Nome contatto",
|
||||
"contatto_ruolo" => "Ruolo contatto",
|
||||
"contatto_email" => "Email contatto",
|
||||
"test_ab" => "Test A/B",
|
||||
"progetto" => "Nome progetto",
|
||||
"piano" => "Piano / prodotto"
|
||||
}
|
||||
end
|
||||
|
||||
def variables_for(organization: nil, contact: nil, project: nil, opportunity: nil, ab_variant: nil)
|
||||
org = organization
|
||||
contact ||= org&.primary_contact || org&.contacts&.first
|
||||
opportunity ||= org&.campaign_opportunity(project) if org && project
|
||||
|
||||
{
|
||||
"societa" => org&.name,
|
||||
"organizzazione.nome" => org&.name,
|
||||
"regione" => org&.region,
|
||||
"provincia" => org&.province,
|
||||
"citta" => org&.city,
|
||||
"email" => (contact&.email.presence || org&.email),
|
||||
"sito" => org&.website,
|
||||
"sport" => org&.sport,
|
||||
"mf" => org&.team_gender_label,
|
||||
"streaming" => org&.streaming_status_label,
|
||||
"fit" => org&.commercial_fit,
|
||||
"n_lista" => org&.list_position,
|
||||
"contatto_nome" => contact&.full_name,
|
||||
"contatto_ruolo" => contact&.role,
|
||||
"contatto_email" => contact&.email,
|
||||
"test_ab" => ab_variant.presence || opportunity&.ab_variant,
|
||||
"progetto" => project&.name,
|
||||
"piano" => opportunity&.product
|
||||
}.transform_values { |value| value.to_s }
|
||||
end
|
||||
|
||||
def render(template, **context)
|
||||
vars = variables_for(**context)
|
||||
template.to_s.gsub(TOKEN) do
|
||||
key = Regexp.last_match(1).to_s.downcase
|
||||
vars[key].to_s
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
class Mailings::InlineImages
|
||||
BLOB_PATH = %r{/rails/active_storage/blobs/(?:redirect/|proxy/)?([^/?#]+)}
|
||||
|
||||
def self.call(mailer, html)
|
||||
new(mailer).call(html)
|
||||
end
|
||||
|
||||
def initialize(mailer)
|
||||
@mailer = mailer
|
||||
end
|
||||
|
||||
def call(html)
|
||||
fragment = Nokogiri::HTML::DocumentFragment.parse(html.to_s)
|
||||
fragment.css("img").each_with_index do |img, index|
|
||||
blob = blob_from(img["src"])
|
||||
next unless blob
|
||||
|
||||
name = "inline-#{index}-#{blob.filename}"
|
||||
@mailer.attachments.inline[name] = {
|
||||
mime_type: blob.content_type,
|
||||
content: blob.download
|
||||
}
|
||||
img["src"] = @mailer.attachments[name].url
|
||||
width = img["width"].presence
|
||||
styles = ["max-width: 100%", "height: auto"]
|
||||
styles << "width: #{width}px" if width.present?
|
||||
img["style"] = [img["style"], *styles].compact.join("; ")
|
||||
end
|
||||
|
||||
fragment.css("figure").each do |figure|
|
||||
image = figure.at_css("img")
|
||||
image ? figure.replace(image) : figure.remove
|
||||
end
|
||||
|
||||
fragment.to_html
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def blob_from(src)
|
||||
signed_id = src.to_s[BLOB_PATH, 1]
|
||||
return if signed_id.blank?
|
||||
|
||||
ActiveStorage::Blob.find_signed(signed_id)
|
||||
rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveRecord::RecordNotFound
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,67 @@
|
||||
class Mailings::RecipientBuilder
|
||||
def initialize(mailing)
|
||||
@mailing = mailing
|
||||
end
|
||||
|
||||
def call
|
||||
split_index = 0
|
||||
ordered_scope.each do |org|
|
||||
contact = org.primary_contact || org.contacts.min_by(&:id)
|
||||
email = contact&.email.presence || org.email.presence
|
||||
attrs = {
|
||||
organization: org,
|
||||
contact: contact,
|
||||
email: email&.downcase
|
||||
}
|
||||
if email.blank?
|
||||
attrs[:status] = "skipped"
|
||||
attrs[:skip_reason] = "manca email"
|
||||
elsif !email.match?(URI::MailTo::EMAIL_REGEXP)
|
||||
attrs[:status] = "skipped"
|
||||
attrs[:skip_reason] = "email non valida"
|
||||
else
|
||||
attrs[:status] = "pending"
|
||||
end
|
||||
assign_variant!(attrs, org, split_index)
|
||||
split_index += 1 if attrs[:status] == "pending"
|
||||
@mailing.mailing_recipients.create!(attrs)
|
||||
end
|
||||
@mailing.mailing_recipients.ordered
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ordered_scope
|
||||
scope.reorder(Arel.sql("organizations.list_position ASC NULLS LAST"), "organizations.name ASC")
|
||||
end
|
||||
|
||||
def scope
|
||||
orgs = Organization.for_project(@mailing.project).includes(:contacts, :opportunities)
|
||||
org_ids =
|
||||
case @mailing.audience
|
||||
when "to_send"
|
||||
Opportunity.where(project_id: @mailing.project_id, send_status: "to_send").select(:organization_id)
|
||||
when "test_a"
|
||||
Opportunity.where(project_id: @mailing.project_id, ab_variant: "A").select(:organization_id)
|
||||
when "test_b"
|
||||
Opportunity.where(project_id: @mailing.project_id, ab_variant: "B").select(:organization_id)
|
||||
else
|
||||
orgs.select(:id)
|
||||
end
|
||||
orgs.where(id: org_ids)
|
||||
end
|
||||
|
||||
def assign_variant!(attrs, org, split_index)
|
||||
return unless @mailing.ab_test?
|
||||
|
||||
opportunity = org.campaign_opportunity(@mailing.project)
|
||||
recorded = opportunity&.ab_variant.to_s.upcase
|
||||
variant =
|
||||
if @mailing.ab_from_record? && recorded.in?(%w[A B])
|
||||
recorded
|
||||
else
|
||||
split_index.even? ? "A" : "B"
|
||||
end
|
||||
attrs[:ab_variant] = variant
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,81 @@
|
||||
class Reports::Builder
|
||||
def initialize(project: nil)
|
||||
@project = project
|
||||
@opp_scope = project ? Opportunity.for_project(project) : Opportunity.all
|
||||
@org_scope = project ? Organization.for_project(project) : Organization.all
|
||||
end
|
||||
|
||||
def funnel
|
||||
metrics = Dashboard::Metrics.new(scope: @opp_scope, project: @project)
|
||||
{
|
||||
stages: Catalog::PIPELINE_ORDER.map { |s| { stage: s, label: Catalog.label_for(Catalog::PIPELINE_STAGES, s), count: metrics.stage_counts[s] } },
|
||||
conversions: metrics.conversion_rates,
|
||||
funnel: metrics.funnel_steps
|
||||
}
|
||||
end
|
||||
|
||||
def lead_sources
|
||||
Catalog::LEAD_SOURCES.map do |key, label|
|
||||
orgs = @org_scope.where(lead_source: key)
|
||||
opps = @opp_scope.joins(:organization).where(organizations: { lead_source: key })
|
||||
won = opps.won.count
|
||||
leads = orgs.count
|
||||
{
|
||||
key: key,
|
||||
label: label,
|
||||
leads: leads,
|
||||
opportunities: opps.count,
|
||||
customers: won,
|
||||
conversion_rate: leads.zero? ? 0 : ((won.to_f / leads) * 100).round
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def won_lost_by_month(months: 6)
|
||||
start_date = months.months.ago.beginning_of_month
|
||||
won = @opp_scope.won.where("won_at >= ?", start_date).group("DATE_TRUNC('month', won_at)").count
|
||||
lost = @opp_scope.lost.where("lost_at >= ?", start_date).group("DATE_TRUNC('month', lost_at)").count
|
||||
keys = (won.keys + lost.keys).uniq.sort
|
||||
keys.map do |month|
|
||||
{
|
||||
month: month.to_date,
|
||||
won: won[month] || 0,
|
||||
lost: lost[month] || 0
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def lost_reasons
|
||||
@opp_scope.lost.group(:lost_reason).count.map do |reason, count|
|
||||
{
|
||||
key: reason,
|
||||
label: Catalog.label_for(Catalog::LOST_REASONS, reason),
|
||||
count: count
|
||||
}
|
||||
end.sort_by { |r| -r[:count] }
|
||||
end
|
||||
|
||||
def sales_owners
|
||||
User.active.map do |user|
|
||||
open_opps = user.assigned_opportunities.merge(@opp_scope).open_stage
|
||||
won_opps = user.assigned_opportunities.merge(@opp_scope).won
|
||||
{
|
||||
user: user,
|
||||
open_count: open_opps.count,
|
||||
open_value: open_opps.sum(:estimated_value).to_f,
|
||||
won_count: won_opps.count,
|
||||
won_value: won_opps.sum(:estimated_value).to_f
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def revenue_by_month(months: 6)
|
||||
start_date = months.months.ago.beginning_of_month
|
||||
@opp_scope.won
|
||||
.where("won_at >= ?", start_date)
|
||||
.group("DATE_TRUNC('month', won_at)")
|
||||
.sum(:estimated_value)
|
||||
.sort_by { |month, _| month }
|
||||
.map { |month, value| { month: month.to_date, value: value.to_f } }
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user