iachat/enterprise/app/models/captain/reservation.rb
Rodribm10 8e0a06246b feat(lifecycle): wire Captain::Reservation lifecycle hooks
Add after_commit callbacks to call Captain::Lifecycle::Scheduler on
create, status change (cancelled/no_show), and check_in_at change.
Each handler wraps in rescue StandardError to preserve existing behavior.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 01:37:23 -03:00

205 lines
8.3 KiB
Ruby

# == 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