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>
78 lines
2.4 KiB
Ruby
78 lines
2.4 KiB
Ruby
# frozen_string_literal: true
|
||
|
||
# Dispara mensagem de re-engajamento pra clientes que sumiram.
|
||
# Critérios:
|
||
# - 2+ reservas pagas no histórico
|
||
# - Última reserva há 60+ dias
|
||
# - Nenhum outreach nos últimos 180 dias (custom_attributes.churn_outreach_at)
|
||
# Segurança:
|
||
# - Só dias úteis 10h–18h BRT (cron garante)
|
||
# - Máximo 20 mensagens por account por dia
|
||
class Captain::Retention::ChurnOutreachSchedulerJob < ApplicationJob
|
||
MIN_PAID_RESERVATIONS = 2
|
||
DAYS_SILENT = 60
|
||
COOLDOWN_DAYS = 180
|
||
MAX_PER_ACCOUNT_DAY = 20
|
||
|
||
queue_as :scheduled_jobs
|
||
|
||
def perform
|
||
return unless within_business_hours?
|
||
|
||
Account.find_each do |account|
|
||
# Skip accounts without any captain assistant
|
||
next unless Captain::Assistant.exists?(account_id: account.id)
|
||
|
||
eligible = find_eligible_contacts(account)
|
||
next if eligible.empty?
|
||
|
||
already_sent_today = sent_today_count(account)
|
||
budget = MAX_PER_ACCOUNT_DAY - already_sent_today
|
||
next if budget <= 0
|
||
|
||
eligible.limit(budget).each do |contact|
|
||
Captain::Retention::ChurnOutreachJob.perform_later(contact.id)
|
||
end
|
||
end
|
||
rescue StandardError => e
|
||
Rails.logger.error("[ChurnOutreachScheduler] #{e.class}: #{e.message}")
|
||
end
|
||
|
||
private
|
||
|
||
def within_business_hours?
|
||
now = Time.current.in_time_zone('America/Sao_Paulo')
|
||
return false if now.saturday? || now.sunday?
|
||
|
||
(10..17).cover?(now.hour)
|
||
end
|
||
|
||
def find_eligible_contacts(account)
|
||
cutoff_last_res = DAYS_SILENT.days.ago
|
||
cutoff_outreach = COOLDOWN_DAYS.days.ago.iso8601
|
||
|
||
# Contatos com 2+ reservas PAGAS e última reserva antes do cutoff
|
||
contact_ids = Captain::Reservation
|
||
.where(account_id: account.id, payment_status: 'paid')
|
||
.group(:contact_id)
|
||
.having('COUNT(*) >= ? AND MAX(check_in_at) < ?', MIN_PAID_RESERVATIONS, cutoff_last_res)
|
||
.pluck(:contact_id)
|
||
|
||
return Contact.none if contact_ids.empty?
|
||
|
||
account.contacts
|
||
.where(id: contact_ids)
|
||
.where(
|
||
"custom_attributes ->> 'churn_outreach_at' IS NULL OR custom_attributes ->> 'churn_outreach_at' < ?",
|
||
cutoff_outreach
|
||
)
|
||
end
|
||
|
||
def sent_today_count(account)
|
||
today = Time.zone.today.iso8601
|
||
account.contacts
|
||
.where("custom_attributes ->> 'churn_outreach_at' >= ?", today)
|
||
.count
|
||
end
|
||
end
|