Consolida o trabalho desta branch de abril/2026 em um bloco pronto pra testar em staging antes do merge pra main. ## Correções de memória semântica - ExtractionService: Princípio Zero + Regra de Ouro (ação consumada vs intenção). - Cenário Daniela_Reservas: Passo 0 de classificação (consulta/intenção/fora). ## Roleta da Sorte (end-to-end) - Schema Supabase + 7 RPCs atômicas (server-side, idempotentes). - Services: Offer, Redeem, WeeklyReport. - Jobs: OfferRouletteJob (hook em ConfirmationService após Pix pago), NotifyRevealed + Scheduler de fallback. - Tool manual GenerateRoletaLinkTool + endpoint público /roleta/notify. - Dashboard /captain/roleta com Resgate + Relatório + anomaly detection. ## Cenário Reclamacoes_Ouvidoria - Triagem P1-P4, framework LAST, Three-level listening, Self-check. - Sem compensação material, detecção de cliente frustrado eleva prioridade. ## Analytics - Funil de conversão /captain/funnel: 5 etapas via regex, zero LLM. - Detector de churn via ChurnOutreach* (cron dias úteis 10h-17h BRT). ## Trabalho pré-existente incluído - Captain Executive Reports (ceo_digest, mattermost_delivery). - get_reserva_preco_tool, Lifecycle ajustes, Reservations UI polimentos. ## Outros - .gitignore: patterns pra credenciais. - Migrations de scenarios idempotentes. - i18n completa pt_BR+en pra roleta/funnel. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
166 lines
5.4 KiB
Ruby
166 lines
5.4 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# Funil de conversão 5-etapas: perguntou preço → recebeu preço →
|
|
# reserva iniciada → Pix gerado → Pix pago. Identifica drop-off.
|
|
class Captain::Reports::ConversionFunnelService
|
|
DEFAULT_PERIOD_DAYS = 30
|
|
MAX_PERIOD_DAYS = 180
|
|
|
|
PRICE_QUESTION_RE = /(pre[çc]o|quanto\s+custa|valor\b|tabela|or[çc]amento|qual\s+o\s+pre)/i
|
|
PRICE_ANSWER_RE = /r\$\s*\d|\b\d{2,3}\s*reais\b|\bcus(ta|tam)\b.*\d/i
|
|
|
|
SUITE_RE = /\b(alexa|stilo|est[íi]lo|hidro(massagem)?|banheira|jacuzzi|of[uú]r[óo])\b/i
|
|
|
|
STAGES = [
|
|
{ key: 'price_inquiry', label: 'Perguntou preço' },
|
|
{ key: 'price_answered', label: 'Recebeu cotação' },
|
|
{ key: 'reservation_drafted', label: 'Reserva iniciada' },
|
|
{ key: 'pix_generated', label: 'Pix gerado' },
|
|
{ key: 'pix_paid', label: 'Pix pago' }
|
|
].freeze
|
|
|
|
def initialize(account:, period_days: DEFAULT_PERIOD_DAYS)
|
|
@account = account
|
|
@period_days = [period_days.to_i, MAX_PERIOD_DAYS].min
|
|
@period_days = DEFAULT_PERIOD_DAYS if @period_days <= 0
|
|
@period_end = Time.current
|
|
@period_start = @period_end - @period_days.days
|
|
end
|
|
|
|
def call
|
|
conversations = scope_conversations
|
|
return empty_report if conversations.empty?
|
|
|
|
analyzed = conversations.map { |conv| analyze_conversation(conv) }
|
|
funnel = build_funnel(analyzed)
|
|
by_suite = build_by_suite(analyzed)
|
|
|
|
{
|
|
period_start: @period_start.iso8601,
|
|
period_end: @period_end.iso8601,
|
|
period_days: @period_days,
|
|
total_conversations_analyzed: analyzed.size,
|
|
funnel: funnel,
|
|
by_suite: by_suite,
|
|
top_drop_off: compute_top_drop_off(funnel)
|
|
}
|
|
rescue StandardError => e
|
|
Rails.logger.error("[ConversionFunnelService] #{e.class}: #{e.message}")
|
|
empty_report(error: e.message)
|
|
end
|
|
|
|
private
|
|
|
|
def empty_report(error: nil)
|
|
{
|
|
period_start: @period_start.iso8601,
|
|
period_end: @period_end.iso8601,
|
|
period_days: @period_days,
|
|
total_conversations_analyzed: 0,
|
|
funnel: STAGES.map { |s| s.merge(count: 0, conversion: nil) },
|
|
by_suite: {},
|
|
top_drop_off: nil,
|
|
error: error
|
|
}.compact
|
|
end
|
|
|
|
# Conversas do período em que Captain::Assistant participou.
|
|
def scope_conversations
|
|
@account.conversations
|
|
.joins(:messages)
|
|
.where('conversations.created_at >= ? AND conversations.created_at <= ?', @period_start, @period_end)
|
|
.where(messages: { sender_type: 'Captain::Assistant' })
|
|
.distinct
|
|
.includes(:messages)
|
|
end
|
|
|
|
# Para cada conversa, determina o MAX stage alcançado + a categoria mencionada.
|
|
# rubocop:disable Metrics/AbcSize
|
|
def analyze_conversation(conv)
|
|
messages = conv.messages.where(private: false).order(:created_at).to_a
|
|
incoming = messages.select { |m| m.message_type == 'incoming' }
|
|
outgoing = messages.select { |m| m.message_type == 'outgoing' && m.sender_type == 'Captain::Assistant' }
|
|
|
|
max_stage = nil
|
|
max_stage = 'price_inquiry' if incoming.any? { |m| PRICE_QUESTION_RE.match?(m.content.to_s) }
|
|
max_stage = 'price_answered' if max_stage && outgoing.any? { |m| PRICE_ANSWER_RE.match?(m.content.to_s) }
|
|
|
|
reservation = Captain::Reservation.where(conversation_id: conv.id).order(created_at: :asc).first
|
|
|
|
if reservation
|
|
max_stage ||= 'reservation_drafted'
|
|
max_stage = 'reservation_drafted' if STAGES.index { |s| s[:key] == max_stage } < 2
|
|
end
|
|
|
|
max_stage = 'pix_generated' if reservation && pix_charge?(reservation) && (STAGES.index { |s| s[:key] == max_stage }.to_i < 3)
|
|
|
|
max_stage = 'pix_paid' if reservation && reservation.payment_status.to_s == 'paid'
|
|
|
|
{
|
|
conversation_id: conv.id,
|
|
max_stage: max_stage,
|
|
suite: detect_suite(messages)
|
|
}
|
|
end
|
|
|
|
# rubocop:enable Metrics/AbcSize
|
|
|
|
def pix_charge?(reservation)
|
|
Captain::PixCharge.exists?(reservation_id: reservation.id)
|
|
end
|
|
|
|
def detect_suite(messages)
|
|
messages.each do |m|
|
|
next if m.content.blank?
|
|
|
|
md = SUITE_RE.match(m.content)
|
|
next unless md
|
|
|
|
word = md[1].downcase
|
|
return 'Alexa' if word == 'alexa'
|
|
return 'Stilo' if %w[stilo estilo estílo].include?(word)
|
|
return 'Hidromassagem' if %w[hidro hidromassagem banheira jacuzzi ofuro ofurô].include?(word)
|
|
end
|
|
nil
|
|
end
|
|
|
|
def build_funnel(analyzed)
|
|
# Conta cumulativamente: quem chegou em "reservation_drafted" também conta em "price_answered" e "price_inquiry".
|
|
stage_order = STAGES.pluck(:key)
|
|
counts = Hash.new(0)
|
|
analyzed.each do |row|
|
|
max_index = stage_order.index(row[:max_stage])
|
|
next unless max_index
|
|
|
|
stage_order[0..max_index].each { |k| counts[k] += 1 }
|
|
end
|
|
|
|
previous = nil
|
|
STAGES.map do |stage|
|
|
count = counts[stage[:key]]
|
|
conversion = previous&.positive? ? (count.to_f / previous).round(3) : nil
|
|
previous = count
|
|
stage.merge(count: count, conversion: conversion)
|
|
end
|
|
end
|
|
|
|
def build_by_suite(analyzed)
|
|
grouped = analyzed.group_by { |row| row[:suite] }.reject { |k, _| k.nil? }
|
|
grouped.transform_values { |rows| build_funnel(rows).map { |s| s.slice(:key, :label, :count) } }
|
|
end
|
|
|
|
def compute_top_drop_off(funnel)
|
|
drops = funnel.each_cons(2).filter_map do |from, to|
|
|
next nil unless from[:count].positive?
|
|
|
|
lost = from[:count] - to[:count]
|
|
pct = lost.to_f / from[:count]
|
|
{ from: from[:key], to: to[:key], lost: lost, drop_pct: pct.round(3) }
|
|
end
|
|
|
|
return nil if drops.empty?
|
|
|
|
drops.max_by { |d| d[:drop_pct] }
|
|
end
|
|
end
|