Primeira onda do roadmap de indicadores executivos do Grupo Nova. Mede ADOÇÃO DO CANAL DIGITAL, não a operação total — banner explícito alerta que reservas fechadas manualmente na recepção ainda não estão capturadas (Onda 1B vai adicionar marcação manual via botão na conversa). Backend: - V2::Reports::ConversionFunnelBuilder — leads (novo/retorno/total), reservas (criadas != draft, pagas in active/completed/confirmed), taxas de conversão. Filtro opcional por inbox. - V2::Reports::InboxBenchmarkingBuilder — uma linha por inbox com brand_name (via Captain::UnitInbox -> Unit -> Brand) - Endpoints GET /reports/conversion_funnel e /reports/inbox_benchmarking - RSpec do ConversionFunnelBuilder Frontend: - Rota top-level Reports → Painel Diretoria - DirectoryDashboard.vue: banner de adoção + filtros + cards + funil + tabela benchmarking agrupada por marca com variação vs média - API client getConversionFunnel + getInboxBenchmarking - i18n EN + PT Memórias suporte: feedback_metricas_adocao_canal.md + project_painel_diretoria_roadmap.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
98 lines
2.9 KiB
Ruby
98 lines
2.9 KiB
Ruby
class V2::Reports::ConversionFunnelBuilder
|
||
include DateRangeHelper
|
||
|
||
# Reservation statuses we treat as "paid" — covers PIX (Inter), payments at the
|
||
# reception, card on arrival, etc. Anything that means the booking went through.
|
||
PAID_STATUSES = %w[active completed confirmed].freeze
|
||
|
||
# Statuses we ignore from "created" (drafts are pre-save, never went live)
|
||
IGNORED_CREATED_STATUSES = %w[draft].freeze
|
||
|
||
attr_reader :account, :params
|
||
|
||
def initialize(account, params)
|
||
@account = account
|
||
@params = params
|
||
end
|
||
|
||
def metrics
|
||
{
|
||
leads: leads_breakdown,
|
||
reservations: reservations_breakdown,
|
||
conversion_rates: conversion_rates_breakdown
|
||
}
|
||
end
|
||
|
||
private
|
||
|
||
def filter_inbox_id
|
||
@filter_inbox_id ||= params[:inbox_id].presence&.to_i
|
||
end
|
||
|
||
def conversations_in_period
|
||
@conversations_in_period ||= begin
|
||
scope = account.conversations.where(created_at: range)
|
||
scope = scope.where(inbox_id: filter_inbox_id) if filter_inbox_id
|
||
scope
|
||
end
|
||
end
|
||
|
||
def reservations_in_period
|
||
@reservations_in_period ||= begin
|
||
scope = account.captain_reservations.where(created_at: range)
|
||
scope = scope.where(inbox_id: filter_inbox_id) if filter_inbox_id
|
||
scope.where.not(status: IGNORED_CREATED_STATUSES)
|
||
end
|
||
end
|
||
|
||
# Leads classified using the same logic as the "Novas × Retorno" tab:
|
||
# new = no prior conversation in any inbox of the account
|
||
# returning = had a prior conversation
|
||
def leads_breakdown
|
||
total = conversations_in_period.count
|
||
return { total: 0, new: 0, returning: 0 } if total.zero?
|
||
|
||
conv_with_prior_ids = conversations_in_period
|
||
.joins('INNER JOIN conversations prev ON prev.contact_id = conversations.contact_id ' \
|
||
'AND prev.account_id = conversations.account_id ' \
|
||
'AND prev.id < conversations.id')
|
||
.distinct
|
||
.pluck(:id)
|
||
|
||
returning = conv_with_prior_ids.size
|
||
{
|
||
total: total,
|
||
new: total - returning,
|
||
returning: returning
|
||
}
|
||
end
|
||
|
||
def reservations_breakdown
|
||
created = reservations_in_period.count
|
||
paid = reservations_in_period.where(status: PAID_STATUSES).count
|
||
|
||
{
|
||
created: created,
|
||
paid: paid
|
||
}
|
||
end
|
||
|
||
def conversion_rates_breakdown
|
||
leads_total = conversations_in_period.count
|
||
reservations_paid = reservations_in_period.where(status: PAID_STATUSES).count
|
||
reservations_created = reservations_in_period.count
|
||
|
||
{
|
||
lead_to_paid_reservation: percent(reservations_paid, leads_total),
|
||
lead_to_any_reservation: percent(reservations_created, leads_total),
|
||
created_to_paid: percent(reservations_paid, reservations_created)
|
||
}
|
||
end
|
||
|
||
def percent(numerator, denominator)
|
||
return 0 if denominator.to_i.zero?
|
||
|
||
(numerator.to_f / denominator * 100).round(1)
|
||
end
|
||
end
|