# == Schema Information # # Table name: captain_reservations # # id :bigint not null, primary key # check_in_at :datetime not null # check_out_at :datetime not null # created_by_type :string # metadata :jsonb not null # payment_status :string default("pending") # status :integer default("scheduled"), not null # suite_identifier :string # total_amount :decimal(10, 2) # created_at :datetime not null # updated_at :datetime not null # account_id :bigint not null # captain_brand_id :bigint # captain_unit_id :bigint # contact_id :bigint not null # contact_inbox_id :bigint not null # conversation_id :bigint # created_by_id :bigint # current_pix_charge_id :bigint # inbox_id :bigint not null # integracao_id :string # # Indexes # # idx_reservations_account_payment_status (account_id,payment_status) # idx_reservations_account_status (account_id,status) # idx_reservations_board_unit_checkin_status (captain_unit_id,check_in_at,status) # idx_reservations_board_unit_checkout_status (captain_unit_id,check_out_at,status) # index_captain_reservations_on_account_id (account_id) # index_captain_reservations_on_account_id_and_inbox_id (account_id,inbox_id) # index_captain_reservations_on_captain_brand_id (captain_brand_id) # index_captain_reservations_on_captain_unit_id (captain_unit_id) # index_captain_reservations_on_contact_id (contact_id) # index_captain_reservations_on_contact_id_and_inbox_id (contact_id,inbox_id) # index_captain_reservations_on_contact_inbox_id (contact_inbox_id) # index_captain_reservations_on_conversation_id (conversation_id) # index_captain_reservations_on_inbox_id (inbox_id) # index_captain_reservations_on_integracao_id (integracao_id) # index_captain_reservations_on_integracao_id_and_unit_id (integracao_id,captain_unit_id) UNIQUE # # Foreign Keys # # fk_rails_... (account_id => accounts.id) # fk_rails_... (captain_brand_id => captain_brands.id) # fk_rails_... (captain_unit_id => captain_units.id) # fk_rails_... (contact_id => contacts.id) # fk_rails_... (contact_inbox_id => contact_inboxes.id) # fk_rails_... (conversation_id => conversations.id) # fk_rails_... (inbox_id => inboxes.id) # class Captain::Reservation < ApplicationRecord self.table_name = 'captain_reservations' belongs_to :account belongs_to :inbox belongs_to :contact belongs_to :contact_inbox belongs_to :conversation, class_name: '::Conversation', optional: true belongs_to :brand, class_name: 'Captain::Brand', foreign_key: 'captain_brand_id', optional: true, inverse_of: false belongs_to :unit, class_name: 'Captain::Unit', foreign_key: 'captain_unit_id', optional: true, inverse_of: false belongs_to :current_pix_charge, class_name: 'Captain::PixCharge', optional: true enum status: { scheduled: 0, active: 1, completed: 2, cancelled: 3, pending_payment: 4, draft: 5, confirmed: 6 } validates :suite_identifier, presence: true validates :check_in_at, presence: true validates :check_out_at, presence: true validate :check_out_after_check_in scope :filter_by_status, ->(status) { where(status: status) if status.present? && status != 'all' } scope :filter_by_date_range, lambda { |from, to| if from.present? && to.present? where(check_in_at: from..to) elsif from.present? where('check_in_at >= ?', from) elsif to.present? where('check_in_at <= ?', to) end } scope :in_house, -> { where(status: 'active') } delegate :name, :email, :phone_number, to: :contact, prefix: true before_validation :set_captain_unit_id, on: :create after_commit :sync_conversation_marker_snapshot after_create_commit :update_contact_reservation_metadata after_create_commit :post_internal_reservation_note after_create_commit :schedule_lifecycle_rules after_update_commit :handle_lifecycle_status_change, if: :saved_change_to_status? after_update_commit :handle_lifecycle_checkin_change, if: :saved_change_to_check_in_at? def ui_status Captain::Reservations::MarkerBuilder.ui_status(status) end def ui_status_label Captain::Reservations::MarkerBuilder.status_label(ui_status) end private def set_captain_unit_id return if captain_unit_id.present? # Primeiro tenta a associação via CaptainInbox (fluxo principal do Captain). captain_inbox = CaptainInbox.find_by(inbox_id: inbox_id) if captain_inbox&.captain_unit_id.present? self.captain_unit_id = captain_inbox.captain_unit_id return end # Fallback: usa vínculo direto da Unidade Pix com o inbox. linked_unit = Captain::Unit.find_by(account_id: account_id, inbox_id: inbox_id) self.captain_unit_id = linked_unit&.id end def check_out_after_check_in return unless check_in_at.present? && check_out_at.present? errors.add(:check_out_at, 'deve ser posterior ao check-in') if check_out_at <= check_in_at end def sync_conversation_marker_snapshot Captain::Reservations::ConversationMarkerSyncService.new(reservation: self).perform rescue StandardError => e Rails.logger.error("[Captain::Reservation] failed to sync conversation marker: #{e.class} - #{e.message}") end # Cria uma nota interna (privada) na conversa com os detalhes da reserva # recem criada. A atendente humana ve tudo em um so lugar. # rubocop:disable Metrics/AbcSize,Metrics/MethodLength def post_internal_reservation_note return if conversation.blank? meta = metadata.to_h deposit = meta['deposit_amount'].to_f deposit = (total_amount.to_f / 2.0) if deposit <= 0 check_in_fmt = check_in_at&.strftime('%d/%m/%Y %H:%M') || '—' category = meta['category'].presence || suite_identifier content = [ '🛎️ *Nova reserva criada*', "Suíte: #{category}", "Check-in: #{check_in_fmt}", "Valor total: R$ #{format('%.2f', total_amount.to_f)}", "Sinal (PIX 50%): R$ #{format('%.2f', deposit)}", "Reserva ##{id}" ].join("\n") Messages::MessageBuilder.new( nil, conversation, { content: content, message_type: 'outgoing', private: true } ).perform rescue StandardError => e Rails.logger.warn("[Captain::Reservation] failed to post internal note: #{e.class} - #{e.message}") end # rubocop:enable Metrics/AbcSize,Metrics/MethodLength def schedule_lifecycle_rules Captain::Lifecycle::Scheduler.schedule_for(self) rescue StandardError => e Rails.logger.error("[Lifecycle] schedule_for failed for reservation #{id}: #{e.class} #{e.message}") end def handle_lifecycle_status_change return unless %w[cancelled no_show].include?(status.to_s) Captain::Lifecycle::Scheduler.cancel_pending(self) rescue StandardError => e Rails.logger.error("[Lifecycle] cancel_pending failed for reservation #{id}: #{e.class} #{e.message}") end def handle_lifecycle_checkin_change Captain::Lifecycle::Scheduler.reschedule_for_checkin_change(self) rescue StandardError => e Rails.logger.error("[Lifecycle] reschedule failed for reservation #{id}: #{e.class} #{e.message}") end # Atualiza campos visiveis no painel lateral do Chatwoot (custom_attributes) # pra que a recepcionista veja num relance: # ultima_suite, ultima_permanencia, ultima_reserva_em, total_reservas def update_contact_reservation_metadata # rubocop:disable Metrics/AbcSize return if contact.blank? meta = metadata.to_h current = contact.custom_attributes.to_h current['ultima_suite'] = meta['category'].presence || suite_identifier.to_s.split('·').first.to_s.strip current['ultima_permanencia'] = meta['stay_type'].presence || suite_identifier.to_s.split('·').last.to_s.strip current['ultima_reserva_em'] = created_at.iso8601 current['total_reservas'] = (current['total_reservas'].to_i + 1) contact.update_columns(custom_attributes: current) # rubocop:disable Rails/SkipsModelValidations rescue StandardError => e Rails.logger.warn("[Captain::Reservation] failed to update contact metadata: #{e.class} - #{e.message}") end end