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>
126 lines
3.8 KiB
Ruby
126 lines
3.8 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# Marca um cupom como resgatado na recepção. Chama RPC atômica no Supabase,
|
|
# envia msg automática de confirmação pro cliente via Jasmine.
|
|
# rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
class Captain::Roleta::RedeemService
|
|
DEFAULT_SCHEMA = 'reserva_hotel'
|
|
|
|
Result = Struct.new(:success, :error_code, :reservation_id, :prize_nome, :prize_tipo, :prize_valor,
|
|
:contact_phone, :contact_name, :redeemed_at, keyword_init: true)
|
|
|
|
def initialize(code:, receptionist_user:, notes: nil)
|
|
@code = code.to_s.strip.upcase
|
|
@receptionist_user = receptionist_user
|
|
@notes = notes
|
|
end
|
|
|
|
def perform
|
|
return failure('empty_code') if @code.blank?
|
|
return failure('no_receptionist') if @receptionist_user.blank?
|
|
|
|
row = call_rpc
|
|
return failure('rpc_failed') if row.blank?
|
|
|
|
result = Result.new(
|
|
success: row['ok'] == true || row['ok'] == 'true',
|
|
error_code: row['error_code'],
|
|
reservation_id: row['reservation_id'],
|
|
prize_nome: row['prize_nome'],
|
|
prize_tipo: row['prize_tipo'],
|
|
prize_valor: row['prize_valor'],
|
|
contact_phone: row['contact_phone'],
|
|
contact_name: row['contact_name'],
|
|
redeemed_at: row['redeemed_at']
|
|
)
|
|
|
|
dispatch_confirmation_message(result) if result.success
|
|
|
|
result
|
|
rescue StandardError => e
|
|
Rails.logger.error("[Roleta::RedeemService] falha: #{e.class} - #{e.message}")
|
|
failure('exception')
|
|
end
|
|
|
|
private
|
|
|
|
def failure(code)
|
|
Result.new(success: false, error_code: code)
|
|
end
|
|
|
|
def call_rpc
|
|
body = {
|
|
p_code: @code,
|
|
p_receptionist_user_id: @receptionist_user.id,
|
|
p_notes: @notes.presence
|
|
}
|
|
Array(supabase_rpc('mark_draw_redeemed', body)).first
|
|
end
|
|
|
|
def dispatch_confirmation_message(result)
|
|
reservation = Captain::Reservation.find_by(id: result.reservation_id)
|
|
return if reservation.blank?
|
|
|
|
conversation = reservation.conversation
|
|
return if conversation.blank?
|
|
|
|
assistant = conversation.inbox&.captain_assistant
|
|
return if assistant.blank?
|
|
|
|
content = build_message(result)
|
|
Messages::MessageBuilder.new(assistant, conversation, {
|
|
content: content,
|
|
message_type: 'outgoing'
|
|
}).perform
|
|
rescue StandardError => e
|
|
Rails.logger.warn("[Roleta::RedeemService] falha ao mandar msg: #{e.class} - #{e.message}")
|
|
end
|
|
|
|
def build_message(result)
|
|
prize_desc = case result.prize_tipo
|
|
when 'desconto_percentual'
|
|
"#{Integer(Float(result.prize_valor))}% de desconto"
|
|
else
|
|
result.prize_nome
|
|
end
|
|
|
|
horario = Time.current.in_time_zone('America/Sao_Paulo').strftime('%Hh%M')
|
|
|
|
<<~MSG.strip
|
|
✅ Seu prêmio *#{prize_desc}* foi entregue na recepção (#{horario}).
|
|
|
|
Se por algum motivo você NÃO recebeu, me avisa aqui que a gente resolve na hora 🫶
|
|
MSG
|
|
end
|
|
|
|
def supabase_rpc(fn_name, body)
|
|
url = "#{supabase_url}/rest/v1/rpc/#{fn_name}"
|
|
response = Faraday.post(url) do |req|
|
|
req.headers['apikey'] = supabase_key
|
|
req.headers['Authorization'] = "Bearer #{supabase_key}"
|
|
req.headers['Content-Profile'] = supabase_schema
|
|
req.headers['Content-Type'] = 'application/json'
|
|
req.headers['Accept'] = 'application/json'
|
|
req.body = body.to_json
|
|
end
|
|
return [] unless response.success?
|
|
|
|
JSON.parse(response.body)
|
|
rescue JSON::ParserError
|
|
[]
|
|
end
|
|
|
|
def supabase_url
|
|
ENV.fetch('RESERVA_1001_SUPABASE_URL').chomp('/')
|
|
end
|
|
|
|
def supabase_key
|
|
ENV.fetch('RESERVA_1001_SUPABASE_ANON_KEY')
|
|
end
|
|
|
|
def supabase_schema
|
|
ENV.fetch('RESERVA_1001_SUPABASE_SCHEMA', DEFAULT_SCHEMA)
|
|
end
|
|
end
|
|
# rubocop:enable Metrics/MethodLength,Metrics/AbcSize
|