iachat/app/builders/v2/reports/conversion_funnel_builder.rb
Rodribm10 d831ee4d33 feat(reports): Painel Diretoria — Onda 1A (leitura)
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>
2026-04-26 12:44:59 -03:00

98 lines
2.9 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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