feat: persiste metadados do cliente em custom_attributes + tool blindada

- Controller grava cpf/ultima_suite/ultima_permanencia/ultima_reserva_em/total_reservas
  em contact.custom_attributes (aparece no painel lateral do Chatwoot)
- GenerateReservationLinkTool exige marca/unidade/categoria/permanencia/checkin_at;
  retorna erro se Jasmine chamar sem esses dados
This commit is contained in:
Rodribm10 2026-04-14 13:26:02 -03:00
parent 37480b1fc5
commit 7c9411a0b0
2 changed files with 63 additions and 32 deletions

View File

@ -31,13 +31,19 @@ class Public::Api::V1::Captain::PublicReservationsController < ActionController:
name: customer[:name],
phone_number: normalized_phone,
email: customer[:email].presence,
custom_attributes: {
cpf: customer[:cpf].presence
}.compact,
additional_attributes: {
cpf: customer[:cpf].presence,
origem: 'reserva-1001'
}.compact
}
}
).perform
# Atualiza campos visiveis do contato (alem do ContactInboxBuilder, que so
# preenche no create e nao mexe em contato ja existente).
persist_customer_metadata!(contact_inbox.contact, customer, params)
conversation = ConversationBuilder.new(
params: ActionController::Parameters.new(
additional_attributes: {
@ -130,6 +136,30 @@ class Public::Api::V1::Captain::PublicReservationsController < ActionController:
render json: { error: 'unauthorized' }, status: :unauthorized
end
# Persiste CPF, email, ultima suite/permanencia, data e total de reservas
# no contact.custom_attributes para aparecer no painel lateral do Chatwoot
# e pra facilitar reservas futuras (cliente recorrente).
def persist_customer_metadata!(contact, customer, payload) # rubocop:disable Metrics/AbcSize
return if contact.blank?
current_custom = contact.custom_attributes || {}
current_custom = current_custom.dup
current_custom['cpf'] = customer[:cpf] if customer[:cpf].present?
current_custom['ultima_suite'] = payload[:category] if payload[:category].present?
current_custom['ultima_permanencia'] = payload[:stay_type] if payload[:stay_type].present?
current_custom['ultima_reserva_em'] = Time.current.iso8601
current_custom['total_reservas'] = (current_custom['total_reservas'].to_i + 1)
updates = { custom_attributes: current_custom }
updates[:email] = customer[:email] if customer[:email].present? && contact.email.blank?
updates[:name] = customer[:name] if customer[:name].present? && contact.name.blank?
contact.update!(updates)
rescue StandardError => e
Rails.logger.error("[PublicReservations] persist_customer_metadata failed: #{e.message}")
end
# Espelha Captain::Tools::GeneratePixTool#mark_conversation_as_awaiting_payment
# (enterprise/app/services/captain/tools/generate_pix_tool.rb:713-721)
def mark_conversation_as_awaiting_payment(conversation)

View File

@ -14,74 +14,75 @@ class Captain::Tools::GenerateReservationLinkTool < Captain::Tools::BaseTool
def description
<<~DESC.strip
FALLBACK para gerar reserva via pagina publica (Reserva Rede 1001).
Use SOMENTE quando `generate_pix` falhar ou retornar success=false.
Gera um link com os dados ja preenchidos (categoria, permanencia,
data). Nome, telefone e CPF do cliente sao lidos automaticamente do
contato da conversa - voce nao precisa passa-los. Envie o link
retornado como alternativa, explicando que o cliente pode revisar
e pagar pela pagina.
Use SOMENTE quando `generate_pix` retornar success=false na mesma conversa.
OBRIGATORIO passar: marca, unidade, categoria, permanencia, checkin_at.
Nome/telefone/CPF/email vem do contato automaticamente.
Retorna uma URL que DEVE ser enviada ao cliente como texto puro (sem markdown).
DESC
end
def tool_parameters_schema
{
type: 'object',
required: %w[marca unidade categoria permanencia checkin_at],
properties: {
marca: {
type: 'string',
description: 'Nome da marca. Ex: "Hotel 1001 Noites".'
description: 'OBRIGATORIO. Nome da marca. Ex: "Hotel 1001 Noites Prime".'
},
unidade: {
type: 'string',
description: 'Nome da unidade do hotel. Ex: "Hotel 1001 Aguas Lindas".'
},
permanencia: {
type: 'string',
description: 'Permanencia escolhida. Ex: "3hrs", "4hrs", "Pernoite".'
description: 'OBRIGATORIO. Nome da unidade. Ex: "Prime Aguas Lindas".'
},
categoria: {
type: 'string',
description: 'Categoria da suite. Ex: "Standard", "Hidromassagem".'
description: 'OBRIGATORIO. Categoria da suite. Ex: "Alexa", "Stilo", "Hidromassagem".'
},
permanencia: {
type: 'string',
description: 'OBRIGATORIO. Permanencia escolhida. Ex: "2hrs", "3hrs", "4hrs", "Pernoite", "Diaria".'
},
checkin_at: {
type: 'string',
description: 'Data e horario de check-in em ISO 8601. Ex: "2026-04-14T22:00:00".'
},
nome: {
type: 'string',
description: 'Nome completo do cliente.'
},
telefone: {
type: 'string',
description: 'Telefone do cliente (com ou sem DDI).'
},
cpf: {
type: 'string',
description: 'CPF do cliente (apenas numeros ou com pontuacao).'
description: 'OBRIGATORIO. Check-in em ISO 8601. Ex: "2026-04-28T22:00:00". Default: ' \
'22:00 para Pernoite/Diaria, +1h do horario atual para 2hrs/3hrs/4hrs.'
},
email: {
type: 'string',
description: 'Email do cliente (opcional).'
description: 'OPCIONAL. Email do cliente (se tiver).'
},
observacao: {
type: 'string',
description: 'Observacao ou preferencia especial do cliente (opcional).'
description: 'OPCIONAL. Preferencia especial do cliente.'
}
}
}
end
REQUIRED_STAY_FIELDS = %i[marca unidade categoria permanencia checkin_at].freeze
def execute(*args, **params)
actual_params = resolve_params(args, params)
missing = REQUIRED_STAY_FIELDS.reject { |f| actual_params[f].to_s.strip.present? }
if missing.any?
return {
formatted_message: "Nao posso gerar o link sem: #{missing.join(', ')}. " \
'Colete esses dados na conversa e chame a ferramenta de novo.',
success: false,
missing_fields: missing
}
end
conversation = resolve_conversation(args, params)
enriched_params = enrich_with_contact(actual_params, conversation)
base = ENV.fetch('RESERVA_1001_BASE_URL', DEFAULT_BASE_URL)
query = build_query(enriched_params)
url = query.empty? ? base : "#{base}/?#{query}"
url = "#{base}/?#{query}"
{
formatted_message: "Pronto! Clique no link para revisar e pagar a entrada via PIX:\n#{url}",
formatted_message: url,
raw_payload: url,
success: true
}