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>
242 lines
7.9 KiB
Ruby
242 lines
7.9 KiB
Ruby
# rubocop:disable Metrics/ClassLength
|
|
class Api::V1::Accounts::Captain::ReservationsController < Api::V1::Accounts::BaseController
|
|
CONFIRMED_STATUSES = %i[scheduled active completed].freeze
|
|
RESULTS_PER_PAGE = 25
|
|
MAX_RESULTS_PER_PAGE = 100
|
|
SORTABLE_FIELDS = %w[check_in_at created_at updated_at].freeze
|
|
|
|
before_action :current_account
|
|
before_action -> { check_authorization(Captain::Assistant) }
|
|
before_action :set_current_page, only: [:index]
|
|
before_action :set_per_page, only: [:index]
|
|
before_action :set_reservations_scope
|
|
before_action :set_reservation, only: [:show, :pix, :cancel, :mark_as_paid, :regenerate_pix]
|
|
|
|
def index
|
|
scoped = apply_filters(@reservations_scope)
|
|
scoped = apply_sort(scoped)
|
|
@reservations_count = scoped.count
|
|
@reservations = scoped.page(@current_page).per(@per_page)
|
|
end
|
|
|
|
def revenue
|
|
scoped = apply_common_filters(@reservations_scope.where(status: CONFIRMED_STATUSES))
|
|
render json: Captain::Reservations::RevenueSummaryService.new(scope: scoped).perform
|
|
end
|
|
|
|
def show
|
|
@marker = Captain::Reservations::MarkerBuilder.build_for(@reservation)
|
|
end
|
|
|
|
def create
|
|
ActiveRecord::Base.transaction do
|
|
@reservation = @reservations_scope.new(create_params)
|
|
@reservation.created_by = current_user
|
|
@reservation.metadata ||= {}
|
|
|
|
ensure_contact_inbox!
|
|
|
|
if @reservation.save
|
|
@marker = Captain::Reservations::MarkerBuilder.build_for(@reservation)
|
|
render 'api/v1/accounts/captain/reservations/show'
|
|
else
|
|
render json: { error: @reservation.errors.full_messages.join(', ') }, status: :unprocessable_entity
|
|
end
|
|
end
|
|
end
|
|
|
|
def pix
|
|
marker = Captain::Reservations::MarkerBuilder.build_for(@reservation)
|
|
render json: {
|
|
reservation_id: @reservation.id,
|
|
pix_copy_paste: marker['pix_copy_paste'],
|
|
reason: marker['pix_reason'],
|
|
status: marker['pix_status']
|
|
}
|
|
end
|
|
|
|
def cancel
|
|
reason = params[:reason].to_s.strip
|
|
@reservation.metadata ||= {}
|
|
@reservation.metadata['cancelled_by_user_id'] = current_user.id
|
|
@reservation.metadata['cancelled_at'] = Time.current.iso8601
|
|
@reservation.metadata['cancel_reason'] = reason if reason.present?
|
|
@reservation.update!(status: :cancelled)
|
|
@marker = Captain::Reservations::MarkerBuilder.build_for(@reservation)
|
|
render 'api/v1/accounts/captain/reservations/show'
|
|
end
|
|
|
|
def mark_as_paid
|
|
@reservation.metadata ||= {}
|
|
@reservation.metadata['manual_payment_by_user_id'] = current_user.id
|
|
@reservation.metadata['manual_payment_at'] = Time.current.iso8601
|
|
@reservation.metadata['manual_payment_note'] = params[:note].to_s.strip if params[:note].present?
|
|
@reservation.update!(status: :scheduled, payment_status: 'paid')
|
|
@reservation.current_pix_charge&.update!(status: 'paid') if @reservation.current_pix_charge.present?
|
|
@marker = Captain::Reservations::MarkerBuilder.build_for(@reservation)
|
|
render 'api/v1/accounts/captain/reservations/show'
|
|
end
|
|
|
|
def regenerate_pix
|
|
raise 'Reservation not configured for PIX' if @reservation.unit.blank?
|
|
|
|
@reservation.current_pix_charge&.update!(status: 'expired')
|
|
Captain::Inter::CobService.new(@reservation).call
|
|
@reservation.update!(status: :pending_payment)
|
|
@reservation.reload
|
|
@marker = Captain::Reservations::MarkerBuilder.build_for(@reservation)
|
|
render 'api/v1/accounts/captain/reservations/show'
|
|
rescue StandardError => e
|
|
render json: { error: "Falha ao regerar PIX: #{e.message}" }, status: :unprocessable_entity
|
|
end
|
|
|
|
private
|
|
|
|
def set_reservations_scope
|
|
scope = Current.account.captain_reservations
|
|
.includes(:contact, :unit, :conversation, :current_pix_charge)
|
|
@reservations_scope = filter_by_user_inbox_access(scope)
|
|
end
|
|
|
|
# Agentes só enxergam reservas em caixas de entrada que eles podem acessar.
|
|
def filter_by_user_inbox_access(scope)
|
|
return scope if Current.user.administrator?
|
|
|
|
accessible_inbox_ids = Current.user.assigned_inboxes.pluck(:id)
|
|
return scope.none if accessible_inbox_ids.empty?
|
|
|
|
scope.where(inbox_id: accessible_inbox_ids)
|
|
end
|
|
|
|
def set_reservation
|
|
@reservation = @reservations_scope.find(permitted_params[:id])
|
|
end
|
|
|
|
def set_current_page
|
|
@current_page = permitted_params[:page].presence || 1
|
|
end
|
|
|
|
def set_per_page
|
|
requested = permitted_params[:per_page].presence || RESULTS_PER_PAGE
|
|
@per_page = [requested.to_i, MAX_RESULTS_PER_PAGE].min
|
|
@per_page = RESULTS_PER_PAGE if @per_page <= 0
|
|
end
|
|
|
|
def apply_filters(scope)
|
|
apply_status_filter(apply_common_filters(scope))
|
|
end
|
|
|
|
def apply_common_filters(scope)
|
|
scoped = scope
|
|
scoped = apply_date_filter(scoped)
|
|
scoped = scoped.where(captain_unit_id: permitted_params[:unit_id]) if permitted_params[:unit_id].present?
|
|
scoped = apply_suite_filter(scoped)
|
|
apply_search(scoped)
|
|
end
|
|
|
|
def apply_status_filter(scope)
|
|
status = permitted_params[:status].to_s
|
|
return scope if status.blank? || status == 'all'
|
|
|
|
return scope.where(status: CONFIRMED_STATUSES) if status == 'confirmed'
|
|
|
|
return scope unless Captain::Reservation.statuses.key?(status)
|
|
|
|
scope.where(status: status)
|
|
end
|
|
|
|
def apply_suite_filter(scope)
|
|
suite = permitted_params[:suite].to_s.strip
|
|
return scope if suite.blank?
|
|
|
|
scope.where('LOWER(captain_reservations.suite_identifier) LIKE ?', "%#{suite.downcase}%")
|
|
end
|
|
|
|
def apply_date_filter(scope)
|
|
from = parse_date(permitted_params[:date_from])
|
|
to = parse_date(permitted_params[:date_to])
|
|
return scope if from.blank? && to.blank?
|
|
|
|
if from.present? && to.present?
|
|
scope.where(check_in_at: from.beginning_of_day..to.end_of_day)
|
|
elsif from.present?
|
|
scope.where('check_in_at >= ?', from.beginning_of_day)
|
|
else
|
|
scope.where('check_in_at <= ?', to.end_of_day)
|
|
end
|
|
end
|
|
|
|
def apply_search(scope)
|
|
query = permitted_params[:q].to_s.strip
|
|
return scope if query.blank?
|
|
|
|
like = "%#{query.downcase}%"
|
|
|
|
scope.joins(:contact).where(
|
|
"LOWER(contacts.name) LIKE :q OR LOWER(contacts.phone_number) LIKE :q OR LOWER(COALESCE(contacts.custom_attributes ->> 'cpf', '')) LIKE :q",
|
|
q: like
|
|
)
|
|
end
|
|
|
|
def apply_sort(scope)
|
|
return default_order(scope) if permitted_params[:sort].blank?
|
|
|
|
sort_field = permitted_params[:sort].to_s
|
|
return default_order(scope) unless SORTABLE_FIELDS.include?(sort_field)
|
|
|
|
direction = permitted_params[:direction].to_s.downcase == 'asc' ? 'ASC' : 'DESC'
|
|
scope.order(Arel.sql("#{sort_field} #{direction}"))
|
|
end
|
|
|
|
def default_order(scope)
|
|
scope.order(
|
|
Arel.sql(
|
|
'CASE captain_reservations.status ' \
|
|
"WHEN #{Captain::Reservation.statuses[:pending_payment]} THEN 0 " \
|
|
"WHEN #{Captain::Reservation.statuses[:draft]} THEN 1 " \
|
|
"WHEN #{Captain::Reservation.statuses[:scheduled]} THEN 2 " \
|
|
"WHEN #{Captain::Reservation.statuses[:active]} THEN 2 " \
|
|
"WHEN #{Captain::Reservation.statuses[:completed]} THEN 2 " \
|
|
"WHEN #{Captain::Reservation.statuses[:cancelled]} THEN 3 " \
|
|
'ELSE 4 END ASC, captain_reservations.check_in_at ASC'
|
|
)
|
|
)
|
|
end
|
|
|
|
def parse_date(value)
|
|
return nil if value.blank?
|
|
|
|
Date.parse(value)
|
|
rescue ArgumentError
|
|
nil
|
|
end
|
|
|
|
def permitted_params
|
|
params.permit(
|
|
:id, :account_id, :status, :date_from, :date_to, :unit_id, :suite, :q,
|
|
:page, :per_page, :sort, :direction
|
|
)
|
|
end
|
|
|
|
def create_params
|
|
params.require(:reservation).permit(
|
|
:contact_id, :inbox_id, :captain_unit_id, :suite_identifier,
|
|
:check_in_at, :check_out_at, :total_amount
|
|
)
|
|
end
|
|
|
|
def ensure_contact_inbox!
|
|
return if @reservation.contact_inbox_id.present? || @reservation.contact_id.blank? || @reservation.inbox_id.blank?
|
|
|
|
contact_inbox = ContactInbox.find_or_create_by!(
|
|
contact_id: @reservation.contact_id,
|
|
inbox_id: @reservation.inbox_id
|
|
) do |ci|
|
|
ci.source_id = SecureRandom.uuid
|
|
end
|
|
|
|
@reservation.contact_inbox_id = contact_inbox.id
|
|
end
|
|
end
|
|
# rubocop:enable Metrics/ClassLength
|