iachat/enterprise/app/controllers/api/v1/accounts/captain/reservations_controller.rb
Rodribm10 cfffea9c16 feat(captain): semantic memory fixes + roleta + reclamações + analytics
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>
2026-04-21 15:36:25 -03:00

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