-
- {{ t('CAPTAIN_REPORTS.INSIGHT.TOP_TOPICS') }}
+
+
+
+
+
+ {{ totalConversations.toLocaleString() }}
-
-
+ {{ t('CAPTAIN_REPORTS.DASHBOARD.TOTAL_CONVERSATIONS') }}
+
+
+
+
+ {{
+ avgPositivePercent !== null ? avgPositivePercent + '%' : '—'
+ }}
+
+
+ {{ t('CAPTAIN_REPORTS.DASHBOARD.AVG_SENTIMENT') }}
+
+
+
+
+ {{ aggregatedFaqGaps.length }}
+
+
+ {{ t('CAPTAIN_REPORTS.DASHBOARD.FAQ_GAPS_TOTAL') }}
+
+
+
+
+ {{ doneInsightsCount }}
+
+
+ {{ t('CAPTAIN_REPORTS.DASHBOARD.WEEKS_ANALYZED') }}
+
+
+
+
+
+
+
+
+
+ {{ t('CAPTAIN_REPORTS.DASHBOARD.SENTIMENT_TREND') }}
+
+
+
- {{ topic.topic }}
- {{ t('CAPTAIN_REPORTS.INSIGHT.COUNT_PREFIX') }}
- {{ topic.count }}
- {{ t('CAPTAIN_REPORTS.INSIGHT.COUNT_SUFFIX') }}
+
{{ week.label }}
+
+
{{ week.positivePercent }}%
+
+
+
+
+
+ {{ t('CAPTAIN_REPORTS.INSIGHT.SENTIMENT_POSITIVE') }}
+
+
+
+ {{ t('CAPTAIN_REPORTS.INSIGHT.SENTIMENT_NEGATIVE') }}
+
+
+
+ {{ t('CAPTAIN_REPORTS.INSIGHT.SENTIMENT_NEUTRAL') }}
-
+
-
- {{ t('CAPTAIN_REPORTS.INSIGHT.AI_FAILURES') }}
+
+ {{ t('CAPTAIN_REPORTS.DASHBOARD.FAILURES_RANKING') }}
-
- -
+ {{ t('CAPTAIN_REPORTS.DASHBOARD.FAILURES_RANKING_HINT') }}
+
+
+
- {{ t('CAPTAIN_REPORTS.INSIGHT.BULLET') }}
- {{ failure.description }}
-
-
+
{{ idx + 1 }}
+
{{ failure.description }}
+
+ {{ failure.total
+ }}{{ t('CAPTAIN_REPORTS.INSIGHT.TIMES') }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('CAPTAIN_REPORTS.DASHBOARD.FAQ_PRIORITY') }}
+
+
+ {{ t('CAPTAIN_REPORTS.DASHBOARD.FAQ_PRIORITY_HINT') }}
+
+
+
+ {{
+ idx + 1
+ }}
+ {{
+ gap.question
+ }}
+ {{ gap.count
+ }}{{ t('CAPTAIN_REPORTS.INSIGHT.TIMES') }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('CAPTAIN_REPORTS.DASHBOARD.TOP_TOPICS_TITLE') }}
+
+
+
+
{{ topic.topic }}
+
+
{{ topic.count }}
+
+
-
+
-
- {{ insight.payload.period_summary }}
+
+ {{ t('CAPTAIN_REPORTS.DASHBOARD.SUITES_TITLE') }}
+
+
+ {{ idx + 1 }}
+ {{
+ suite.suite
+ }}
+
+ {{ suite.count }}{{ t('CAPTAIN_REPORTS.INSIGHT.TIMES') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('CAPTAIN_REPORTS.DASHBOARD.COMPLAINTS_TREND') }}
+
+
+
+
{{ week.label }}
+
+
{{ week.count }}
+
+
+
+
+
+
+
+ {{ t('CAPTAIN_REPORTS.DASHBOARD.HANDOFFS_TITLE') }}
+
+
+ {{ t('CAPTAIN_REPORTS.DASHBOARD.HANDOFFS_HINT') }}
+
+
+
+
{{ week.label }}
+
+
{{ week.count }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ periodLabel(insight) }}
+
+
+
+ {{ insight.conversations_count }}
+ {{ t('CAPTAIN_REPORTS.INSIGHT.CONVERSATIONS') }}
+ ·
+ {{ insight.messages_count }}
+ {{ t('CAPTAIN_REPORTS.INSIGHT.MESSAGES') }}
+
+
+
+ {{ statusLabel(insight.status) }}
+
+
+
+
+
+
+
+
+
+ {{ sentimentOf(insight).positivePercent }}%
+ {{ t('CAPTAIN_REPORTS.INSIGHT.SENTIMENT_POSITIVE') }}
+
+
+
+ {{ sentimentOf(insight).negativePercent }}%
+ {{ t('CAPTAIN_REPORTS.INSIGHT.SENTIMENT_NEGATIVE') }}
+
+
+
+ {{ sentimentOf(insight).neutralPercent }}%
+ {{ t('CAPTAIN_REPORTS.INSIGHT.SENTIMENT_NEUTRAL') }}
+
+
+
+
+
+
+ {{ insight.payload.period_summary }}
+
+
+
+
+
+ {{ t('CAPTAIN_REPORTS.INSIGHT.TOP_TOPICS') }}
+
+
+
+ {{ topic.topic }}
+ ({{ topic.count }})
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('CAPTAIN_REPORTS.INSIGHT.MOST_REQUESTED_SUITES') }}
+
+
+
+
+ {{ idx + 1 }}
+ {{ suite.suite }}
+
+
+ {{ suite.count
+ }}{{ t('CAPTAIN_REPORTS.INSIGHT.TIMES') }}
+
+
+
+
+
+
+
+
+
+ {{ t('CAPTAIN_REPORTS.INSIGHT.SENTIMENT') }}
+
+
+
+
+ {{ sentimentOf(insight).positive_count }}
+
+
+ {{ t('CAPTAIN_REPORTS.INSIGHT.SENTIMENT_POSITIVE') }}
+
+
+
+
+ {{ sentimentOf(insight).negative_count }}
+
+
+ {{ t('CAPTAIN_REPORTS.INSIGHT.SENTIMENT_NEGATIVE') }}
+
+
+
+
+ {{ sentimentOf(insight).neutral_count }}
+
+
+ {{ t('CAPTAIN_REPORTS.INSIGHT.SENTIMENT_NEUTRAL') }}
+
+
+
+
+ {{ sentimentOf(insight).summary }}
+
+
+
+
+
+
+
+ {{ t('CAPTAIN_REPORTS.INSIGHT.PRAISES') }}
+
+
+
+
+
+
+
+
+ {{ t('CAPTAIN_REPORTS.INSIGHT.COMPLAINTS') }}
+
+
+ -
+
+ {{ complaint }}
+
+
+
+
+
+
+
+
+ {{ t('CAPTAIN_REPORTS.INSIGHT.PRICE_REACTIONS') }}
+
+
+ {{ insight.payload.price_reactions.summary }}
+
+
+
+
+ {{ insight.payload.price_reactions.objections_count }}
+ {{ t('CAPTAIN_REPORTS.INSIGHT.PRICE_OBJECTIONS') }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('CAPTAIN_REPORTS.INSIGHT.AI_FAILURES') }}
+
+
+
+
+ {{ failure.frequency
+ }}{{ t('CAPTAIN_REPORTS.INSIGHT.TIMES') }}
+
+
+ {{ failure.description }}
+
+
+
+
+
+
+
+
+
+
+ {{ t('CAPTAIN_REPORTS.INSIGHT.FAQ_GAPS') }}
+
+
+ {{ insight.payload.faq_gaps.length }}
+
+
+
+ {{ t('CAPTAIN_REPORTS.INSIGHT.FAQ_GAPS_HINT') }}
+
+
+
+ {{
+ gap.question
+ }}
+ {{ gap.frequency
+ }}{{ t('CAPTAIN_REPORTS.INSIGHT.TIMES') }}
+
+
+
+
+
+
+
+
+
+ {{ t('CAPTAIN_REPORTS.INSIGHT.RECOMMENDATIONS') }}
+
+
+
+
@@ -401,4 +1389,81 @@ const periodLabel = insight =>
+
+
+
+
+
+
+ {{ t('CAPTAIN_REPORTS.FAQ_QUICK_ADD.TITLE') }}
+
+
+
+
+
+ {{ quickAddFaq.question }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/javascript/dashboard/store/captain/notificationTemplates.js b/app/javascript/dashboard/store/captain/notificationTemplates.js
new file mode 100644
index 000000000..11e4a2345
--- /dev/null
+++ b/app/javascript/dashboard/store/captain/notificationTemplates.js
@@ -0,0 +1,104 @@
+import CaptainNotificationTemplatesAPI from 'dashboard/api/captain/notificationTemplates';
+import { throwErrorMessage } from 'dashboard/store/utils/api';
+
+const state = {
+ templates: [],
+ uiFlags: {
+ isFetching: false,
+ isCreating: false,
+ isUpdating: false,
+ isDeleting: false,
+ },
+};
+
+const getters = {
+ getTemplates: $state => $state.templates,
+ getUIFlags: $state => $state.uiFlags,
+};
+
+const mutations = {
+ SET_TEMPLATES($state, templates) {
+ $state.templates = templates;
+ },
+ ADD_TEMPLATE($state, template) {
+ $state.templates.push(template);
+ },
+ UPDATE_TEMPLATE($state, updated) {
+ const index = $state.templates.findIndex(t => t.id === updated.id);
+ if (index !== -1) $state.templates.splice(index, 1, updated);
+ },
+ DELETE_TEMPLATE($state, id) {
+ $state.templates = $state.templates.filter(t => t.id !== id);
+ },
+ SET_UI_FLAG($state, flags) {
+ $state.uiFlags = { ...$state.uiFlags, ...flags };
+ },
+};
+
+const actions = {
+ fetch: async ({ commit }, unitId) => {
+ commit('SET_UI_FLAG', { isFetching: true });
+ try {
+ const { data } =
+ await CaptainNotificationTemplatesAPI.getTemplates(unitId);
+ commit('SET_TEMPLATES', data);
+ } catch (error) {
+ throwErrorMessage(error);
+ } finally {
+ commit('SET_UI_FLAG', { isFetching: false });
+ }
+ },
+
+ create: async ({ commit }, { unitId, ...templateData }) => {
+ commit('SET_UI_FLAG', { isCreating: true });
+ try {
+ const { data } = await CaptainNotificationTemplatesAPI.createTemplate(
+ unitId,
+ templateData
+ );
+ commit('ADD_TEMPLATE', data);
+ return data;
+ } catch (error) {
+ return throwErrorMessage(error);
+ } finally {
+ commit('SET_UI_FLAG', { isCreating: false });
+ }
+ },
+
+ update: async ({ commit }, { unitId, id, ...templateData }) => {
+ commit('SET_UI_FLAG', { isUpdating: true });
+ try {
+ const { data } = await CaptainNotificationTemplatesAPI.updateTemplate(
+ unitId,
+ id,
+ templateData
+ );
+ commit('UPDATE_TEMPLATE', data);
+ return data;
+ } catch (error) {
+ return throwErrorMessage(error);
+ } finally {
+ commit('SET_UI_FLAG', { isUpdating: false });
+ }
+ },
+
+ delete: async ({ commit }, { unitId, id }) => {
+ commit('SET_UI_FLAG', { isDeleting: true });
+ try {
+ await CaptainNotificationTemplatesAPI.deleteTemplate(unitId, id);
+ commit('DELETE_TEMPLATE', id);
+ } catch (error) {
+ throwErrorMessage(error);
+ } finally {
+ commit('SET_UI_FLAG', { isDeleting: false });
+ }
+ },
+};
+
+export default {
+ namespaced: true,
+ state,
+ getters,
+ mutations,
+ actions,
+};
diff --git a/app/javascript/dashboard/store/index.js b/app/javascript/dashboard/store/index.js
index 21d937529..f3c9f919b 100755
--- a/app/javascript/dashboard/store/index.js
+++ b/app/javascript/dashboard/store/index.js
@@ -63,6 +63,7 @@ import captainReservations from './captain/reservations';
import captainUnits from './modules/captainUnits';
import captainGalleryItems from './modules/captainGalleryItems';
import captainReports from './modules/captainReports';
+import captainNotificationTemplates from './captain/notificationTemplates';
const plugins = [];
@@ -131,6 +132,7 @@ export default createStore({
captainUnits,
captainGalleryItems,
captainReports,
+ captainNotificationTemplates,
},
plugins,
});
diff --git a/config/agents/tools.yml b/config/agents/tools.yml
index 7cdc8f7a0..e2c66429d 100644
--- a/config/agents/tools.yml
+++ b/config/agents/tools.yml
@@ -49,3 +49,8 @@
title: 'Enviar Fotos de Suíte'
description: 'Envia fotos da galeria da unidade para o cliente, com filtros por categoria/suíte'
icon: 'image'
+
+- id: create_reservation_intent
+ title: 'Criar Reserva'
+ description: 'Cria uma reserva draft quando o cliente confirmar suíte, preço e horário de chegada'
+ icon: 'calendar-add'
diff --git a/config/routes.rb b/config/routes.rb
index 7ec4406df..9115eb91a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -90,7 +90,9 @@ Rails.application.routes.draw do
post :label_suggestion
post :follow_up
end
- resources :units
+ resources :units do
+ resources :notification_templates, only: [:index, :create, :update, :destroy]
+ end
namespace :reports do
resource :operational, only: [:show], controller: 'reports/operational'
resources :insights, only: [:index, :show] do
diff --git a/config/schedule.yml b/config/schedule.yml
index c2d8a02c2..0818a761e 100644
--- a/config/schedule.yml
+++ b/config/schedule.yml
@@ -87,3 +87,10 @@ remove_orphan_conversations_job:
cron: '0 */12 * * *'
class: 'Internal::RemoveOrphanConversationsJob'
queue: housekeeping
+
+# executed every 5 minutes
+# scans reservations and sends configured notification templates (pre/post arrival)
+captain_notification_scanner_job:
+ cron: '*/5 * * * *'
+ class: 'Captain::Notifications::NotificationScannerJob'
+ queue: scheduled_jobs
diff --git a/db/migrate/20260301120000_create_captain_notification_templates.rb b/db/migrate/20260301120000_create_captain_notification_templates.rb
new file mode 100644
index 000000000..6e04c1515
--- /dev/null
+++ b/db/migrate/20260301120000_create_captain_notification_templates.rb
@@ -0,0 +1,18 @@
+class CreateCaptainNotificationTemplates < ActiveRecord::Migration[7.1]
+ def change
+ create_table :captain_notification_templates do |t|
+ t.references :captain_unit, null: false, foreign_key: { to_table: :captain_units }
+ t.string :label, null: false
+ t.text :content, null: false
+ t.integer :timing_minutes, null: false, default: 10
+ t.integer :timing_direction, null: false, default: 0
+ t.boolean :active, null: false, default: true
+ t.integer :position, null: false, default: 0
+
+ t.timestamps
+ end
+
+ add_index :captain_notification_templates, [:captain_unit_id, :active],
+ name: 'idx_notif_templates_unit_active'
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index fbeaf321b..3d0a1f8a0 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.1].define(version: 2026_02_27_120000) do
+ActiveRecord::Schema[7.1].define(version: 2026_03_01_120000) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@@ -534,6 +534,20 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_27_120000) do
t.index ["inbox_id"], name: "index_captain_inboxes_on_inbox_id"
end
+ create_table "captain_notification_templates", force: :cascade do |t|
+ t.bigint "captain_unit_id", null: false
+ t.string "label", null: false
+ t.text "content", null: false
+ t.integer "timing_minutes", default: 10, null: false
+ t.integer "timing_direction", default: 0, null: false
+ t.boolean "active", default: true, null: false
+ t.integer "position", default: 0, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["captain_unit_id", "active"], name: "idx_notif_templates_unit_active"
+ t.index ["captain_unit_id"], name: "index_captain_notification_templates_on_captain_unit_id"
+ end
+
create_table "captain_pix_charges", force: :cascade do |t|
t.bigint "reservation_id", null: false
t.bigint "unit_id", null: false
@@ -1964,6 +1978,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_02_27_120000) do
add_foreign_key "captain_inbox_reminder_settings", "accounts"
add_foreign_key "captain_inbox_reminder_settings", "inboxes"
add_foreign_key "captain_inboxes", "captain_units"
+ add_foreign_key "captain_notification_templates", "captain_units"
add_foreign_key "captain_pix_charges", "captain_reservations", column: "reservation_id"
add_foreign_key "captain_pix_charges", "captain_units", column: "unit_id"
add_foreign_key "captain_pricings", "accounts"
diff --git a/enterprise/app/controllers/api/v1/accounts/captain/notification_templates_controller.rb b/enterprise/app/controllers/api/v1/accounts/captain/notification_templates_controller.rb
new file mode 100644
index 000000000..52aee4d8e
--- /dev/null
+++ b/enterprise/app/controllers/api/v1/accounts/captain/notification_templates_controller.rb
@@ -0,0 +1,52 @@
+class Api::V1::Accounts::Captain::NotificationTemplatesController < Api::V1::Accounts::BaseController
+ before_action :current_account
+ before_action -> { check_authorization(Captain::Assistant) }
+ before_action :set_unit
+ before_action :set_template, only: [:update, :destroy]
+
+ def index
+ @templates = @unit.notification_templates.ordered
+ render json: @templates
+ end
+
+ def create
+ @template = @unit.notification_templates.new(template_params)
+ @template.save!
+ render json: @template, status: :created
+ end
+
+ def update
+ @template.update!(template_params)
+ render json: @template
+ end
+
+ def destroy
+ @template.destroy!
+ head :no_content
+ end
+
+ private
+
+ def set_unit
+ @unit = Current.account.captain_units.find(params[:unit_id])
+ rescue ActiveRecord::RecordNotFound
+ render json: { error: 'Unidade não encontrada' }, status: :not_found
+ end
+
+ def set_template
+ @template = @unit.notification_templates.find(params[:id])
+ rescue ActiveRecord::RecordNotFound
+ render json: { error: 'Template não encontrado' }, status: :not_found
+ end
+
+ def template_params
+ params.require(:notification_template).permit(
+ :label,
+ :content,
+ :timing_minutes,
+ :timing_direction,
+ :active,
+ :position
+ )
+ end
+end
diff --git a/enterprise/app/jobs/captain/notifications/notification_scanner_job.rb b/enterprise/app/jobs/captain/notifications/notification_scanner_job.rb
new file mode 100644
index 000000000..458c3accf
--- /dev/null
+++ b/enterprise/app/jobs/captain/notifications/notification_scanner_job.rb
@@ -0,0 +1,40 @@
+class Captain::Notifications::NotificationScannerJob < ApplicationJob
+ queue_as :scheduled_jobs
+
+ # Tolerance window around the target time (job runs every 5 min, so ±5 min ensures coverage)
+ WINDOW_MINUTES = 5
+
+ def perform
+ Captain::NotificationTemplate.active.find_each do |template|
+ eligible_reservations_for(template).find_each do |reservation|
+ Captain::Notifications::SendNotificationService.new(reservation, template).perform
+ end
+ end
+ end
+
+ private
+
+ def eligible_reservations_for(template)
+ target_time = compute_target_time(template)
+ window_start = target_time - WINDOW_MINUTES.minutes
+ window_end = target_time + WINDOW_MINUTES.minutes
+
+ Captain::Reservation
+ .where(captain_unit_id: template.captain_unit_id)
+ .where(status: Captain::Reservation.statuses.slice(:confirmed, :active).values)
+ .where(check_in_at: window_start..window_end)
+ .where.not(conversation_id: nil)
+ .where(
+ "NOT (metadata->'notified_templates' @> ?::jsonb)",
+ "[#{template.id}]"
+ )
+ end
+
+ def compute_target_time(template)
+ if template.before?
+ template.timing_minutes.minutes.from_now
+ else
+ template.timing_minutes.minutes.ago
+ end
+ end
+end
diff --git a/enterprise/app/models/captain/notification_template.rb b/enterprise/app/models/captain/notification_template.rb
new file mode 100644
index 000000000..e092d184a
--- /dev/null
+++ b/enterprise/app/models/captain/notification_template.rb
@@ -0,0 +1,41 @@
+# == Schema Information
+#
+# Table name: captain_notification_templates
+#
+# id :bigint not null, primary key
+# active :boolean default(TRUE), not null
+# content :text not null
+# label :string not null
+# position :integer default(0), not null
+# timing_direction :integer default("before"), not null
+# timing_minutes :integer default(10), not null
+# created_at :datetime not null
+# updated_at :datetime not null
+# captain_unit_id :bigint not null
+#
+# Indexes
+#
+# idx_notif_templates_unit_active (captain_unit_id,active)
+# index_captain_notification_templates_on_captain_unit_id (captain_unit_id)
+#
+# Foreign Keys
+#
+# fk_rails_... (captain_unit_id => captain_units.id)
+#
+class Captain::NotificationTemplate < ApplicationRecord
+ self.table_name = 'captain_notification_templates'
+
+ belongs_to :unit, class_name: 'Captain::Unit', foreign_key: 'captain_unit_id', inverse_of: :notification_templates
+
+ enum timing_direction: { before: 0, after: 1 }
+
+ validates :label, presence: true
+ validates :content, presence: true
+ validates :timing_minutes, presence: true, numericality: { greater_than: 0 }
+ validates :timing_direction, presence: true
+ validates :captain_unit_id, presence: true
+
+ scope :active, -> { where(active: true) }
+ scope :ordered, -> { order(:position, :id) }
+ scope :for_unit, ->(unit_id) { where(captain_unit_id: unit_id) }
+end
diff --git a/enterprise/app/models/captain/unit.rb b/enterprise/app/models/captain/unit.rb
index c1494eafe..5c7f36a60 100644
--- a/enterprise/app/models/captain/unit.rb
+++ b/enterprise/app/models/captain/unit.rb
@@ -54,6 +54,8 @@ class Captain::Unit < ApplicationRecord
has_many :pix_charges, class_name: 'Captain::PixCharge', dependent: :restrict_with_error
has_many :gallery_items, class_name: 'Captain::GalleryItem', foreign_key: :captain_unit_id, inverse_of: :captain_unit,
dependent: :destroy
+ has_many :notification_templates, class_name: 'Captain::NotificationTemplate', foreign_key: :captain_unit_id,
+ inverse_of: :unit, dependent: :destroy
encrypts :inter_client_secret
encrypts :inter_account_number
diff --git a/enterprise/app/services/captain/notifications/send_notification_service.rb b/enterprise/app/services/captain/notifications/send_notification_service.rb
new file mode 100644
index 000000000..8b9f55e9a
--- /dev/null
+++ b/enterprise/app/services/captain/notifications/send_notification_service.rb
@@ -0,0 +1,54 @@
+class Captain::Notifications::SendNotificationService
+ VARIABLES = {
+ '{{guest_name}}' => ->(r) { r.contact.name.to_s },
+ '{{check_in_time}}' => ->(r) { r.check_in_at.strftime('%H:%M') },
+ '{{check_out_time}}' => ->(r) { r.check_out_at.strftime('%H:%M') },
+ '{{suite_name}}' => ->(r) { r.suite_identifier.to_s },
+ '{{unit_name}}' => ->(r) { r.unit&.name.to_s }
+ }.freeze
+
+ def initialize(reservation, template)
+ @reservation = reservation
+ @template = template
+ end
+
+ def perform
+ return unless @reservation.conversation_id?
+
+ rendered = render_content
+ send_message(rendered)
+ mark_template_sent
+ rescue StandardError => e
+ Rails.logger.error "[SendNotificationService] Failed for reservation #{@reservation.id}, template #{@template.id}: #{e.message}"
+ end
+
+ private
+
+ def render_content
+ content = @template.content.dup
+ VARIABLES.each do |placeholder, resolver|
+ content.gsub!(placeholder, resolver.call(@reservation))
+ end
+ content
+ end
+
+ def send_message(content)
+ conversation = @reservation.conversation
+ assistant = conversation.inbox&.captain_inbox&.assistant
+
+ conversation.messages.create!(
+ content: content,
+ message_type: :outgoing,
+ account: conversation.account,
+ inbox: conversation.inbox,
+ sender: assistant
+ )
+ end
+
+ def mark_template_sent
+ current_notified = @reservation.metadata.to_h.fetch('notified_templates', [])
+ updated = (current_notified + [@template.id]).uniq
+ new_metadata = @reservation.metadata.to_h.merge('notified_templates' => updated)
+ @reservation.update!(metadata: new_metadata)
+ end
+end
diff --git a/enterprise/app/services/captain/tools/create_reservation_intent_tool.rb b/enterprise/app/services/captain/tools/create_reservation_intent_tool.rb
new file mode 100644
index 000000000..345f8b16b
--- /dev/null
+++ b/enterprise/app/services/captain/tools/create_reservation_intent_tool.rb
@@ -0,0 +1,351 @@
+# rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize
+# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Layout/LineLength
+# rubocop:disable Rails/SkipsModelValidations
+# This class was migrated from reference/ with intentional complexity in execute/verify methods.
+# Refactoring is tracked as tech debt rather than done inline.
+class Captain::Tools::CreateReservationIntentTool < Captain::Tools::BaseTool
+ def name
+ 'create_reservation_intent'
+ end
+
+ def description
+ 'Cria uma reserva draft quando o cliente confirmar suíte, preço e horário de chegada. ' \
+ 'IMPORTANTE: Extraia o horário EXATO de chegada da conversa e passe em check_in_at. ' \
+ 'Se o cliente informar duração (ex: "3 horas"), passe em duration_hours para calcular o check-out automaticamente.'
+ end
+
+ def tool_parameters_schema
+ {
+ type: 'object',
+ properties: {
+ suite: {
+ type: 'string',
+ description: 'Nome da suíte/categoria escolhida pelo cliente (ex: Stilo, Master)'
+ },
+ price: {
+ type: 'number',
+ description: 'Valor TOTAL da reserva (sem descontos de sinal). Ex: 60.0'
+ },
+ deposit_value: {
+ type: 'number',
+ description: 'Valor exato a ser cobrado no Pix agora (Sinal). Se informado, substitui o cálculo automático de 50%. Ex: 27.50'
+ },
+ check_in_at: {
+ type: 'string',
+ description: 'Data e horário EXATO de chegada do cliente, extraído da conversa. ' \
+ 'Formatos aceitos: ISO8601 (ex: "2026-03-01T18:30:00"), ' \
+ '"HH:MM" para hoje (ex: "18:30"), ' \
+ 'ou data completa (ex: "01/03/2026 18:30"). ' \
+ 'OBRIGATÓRIO quando o cliente informar o horário de chegada.'
+ },
+ duration_hours: {
+ type: 'number',
+ description: 'Duração da estadia em horas (ex: 3.0). ' \
+ 'Usado para calcular check_out = check_in + duração. ' \
+ 'Passe este campo quando o cliente informar duração em vez de horário de saída.'
+ },
+ check_out_at: {
+ type: 'string',
+ description: 'Data e horário de saída, se informado explicitamente pelo cliente. Formato ISO8601 ou HH:MM.'
+ }
+ },
+ required: %w[suite price]
+ }
+ end
+
+ def execute(*args, **params)
+ actual_params = resolve_params(args, params)
+ Rails.logger.info "[CreateReservationIntentTool] Starting with params: #{actual_params}"
+
+ suite_category = actual_params[:suite]
+ price_raw = actual_params[:price].to_s.gsub(/[^\d,.]/, '').tr(',', '.')
+ price = price_raw.to_f
+
+ deposit_input = actual_params[:deposit_value].to_s.gsub(/[^\d,.]/, '').tr(',', '.')
+ deposit_override = deposit_input.to_f if deposit_input.present?
+
+ last_availability = fetch_last_availability
+
+ if suite_category.blank? || price <= 0
+ inferred = infer_from_history
+ suite_category ||= inferred[:suite]
+ price = inferred[:price].to_f if price <= 0 && inferred[:price].present?
+ end
+
+ if (suite_category.blank? || price <= 0) && last_availability.present?
+ suite_category ||= last_availability[:suite]
+ price = last_availability[:price].to_f if price <= 0 && last_availability[:price].present?
+ end
+
+ intent_error = verify_user_intent_barrier!(suite_category, @conversation)
+ return intent_error if intent_error
+
+ if price.positive? && last_availability.present? && !(deposit_override && deposit_override.positive?) && price_mismatch?(price,
+ last_availability[:price])
+ msg = "ATENÇÃO: Preço (R$ #{format('%.2f',
+ price)}) diverge da última cotação (R$ #{format('%.2f',
+ last_availability[:price])} para #{last_availability[:suite]}). NÃO crie a reserva. Corrija o valor ou peça para o usuário confirmar."
+ Rails.logger.warn "[CreateReservationIntentTool] Price block: tried #{price} but last quote was #{last_availability[:price]}"
+ return msg
+ end
+
+ return "SYSTEM INFO: Você esqueceu de informar a 'suite'. Pergunte ao cliente qual suíte e duração ele deseja." if suite_category.blank?
+
+ return 'SYSTEM INFO: Preço inválido. Use consultar_disponibilidade.' if price <= 0
+
+ ensure_conversation_context!
+
+ return "Erro Crítico: Contexto de conversa não disponível. Params: #{actual_params}" unless @conversation&.inbox
+
+ unit = infer_unit
+ return 'Erro: Unidade não encontrada para esta conversa. Verifique se o Inbox está conectado a uma Unidade.' unless unit
+
+ check_in_at, check_out_at = resolve_check_in_and_out(actual_params)
+
+ recent_draft = Captain::Reservation.where(conversation_id: @conversation.id, status: :draft)
+ .where('created_at > ?', 5.minutes.ago)
+ .where(suite_identifier: suite_category)
+ .order(created_at: :desc)
+ .first
+
+ deposit_amount = if deposit_override&.positive?
+ deposit_override
+ else
+ price / 2.0
+ end
+
+ recent_draft_deposit = recent_draft&.metadata.to_h['deposit_amount'].to_f
+ if recent_draft && (recent_draft_deposit - deposit_amount).abs < 0.1
+ msg = "ATENÇÃO: A reserva JÁ FOI CRIADA anteriormente (ID: #{recent_draft.id}). NÃO crie novamente. Apenas CHAME A FERRAMENTA 'generate_pix' AGORA para finalizar."
+ Rails.logger.info "[CreateReservationIntentTool] Idempotency hit: reusing draft #{recent_draft.id}"
+ return msg
+ end
+
+ Captain::Reservation.where(conversation_id: @conversation.id, status: :draft).update_all(status: :cancelled)
+
+ begin
+ Captain::Reservation.create!(
+ conversation_id: @conversation.id,
+ account: @conversation.account,
+ contact: @conversation.contact,
+ contact_inbox: @conversation.contact_inbox,
+ inbox: @conversation.inbox,
+ captain_unit_id: unit.id,
+ captain_brand_id: unit.captain_brand_id,
+ suite_identifier: suite_category,
+ status: :draft,
+ total_amount: price,
+ check_in_at: check_in_at,
+ check_out_at: check_out_at,
+ metadata: {
+ full_amount: price,
+ deposit_amount: deposit_amount
+ }
+ )
+
+ update_sticky_state(
+ suite: suite_category,
+ price: deposit_amount,
+ check_in_at: check_in_at,
+ check_out_at: check_out_at
+ )
+
+ msg = "Reserva iniciada com sucesso! Check-in: #{check_in_at.strftime('%d/%m/%Y às %H:%M')}. " \
+ "O valor do sinal (50%) é: #{ActiveSupport::NumberHelper.number_to_currency(deposit_amount,
+ unit: 'R$ ', separator: ',', delimiter: '.')}. " \
+ 'INSTRUÇÃO: Como a reserva foi criada com sucesso, avise o cliente e CHAME IMEDIATAMENTE a ferramenta generate_pix para entregar o código de pagamento.'
+ Rails.logger.info '[CreateReservationIntentTool] Reservation created successfully'
+ return msg
+ rescue StandardError => e
+ Rails.logger.error "[CreateReservationIntentTool] Creation failed: #{e.message} | #{e.backtrace&.first}"
+ return "Erro técnico ao criar reserva: #{e.message}"
+ end
+ end
+
+ private
+
+ def verify_user_intent_barrier!(suite_category, conversation)
+ return nil if suite_category.blank?
+
+ all_incoming = conversation&.messages&.incoming&.order(created_at: :asc)&.last(10) || []
+ last_reset_index = all_incoming.rindex { |m| m.content.to_s.downcase.match?(/\b(reiniciar|resetar|comecar de novo)\b/i) }
+
+ relevant_messages = last_reset_index ? all_incoming[(last_reset_index + 1)..] : all_incoming
+ user_text_post_reset = relevant_messages.map(&:content).join(' ').downcase
+ user_text_post_reset = ActiveSupport::Inflector.transliterate(user_text_post_reset).gsub(/[^\w\s]/, '')
+
+ aliases = {
+ 'hidromassagem' => %w[hidro banheira jacuzzi hidromassagem],
+ 'stilo' => %w[stilo estilo],
+ 'master' => %w[master],
+ 'alexa' => %w[alexa]
+ }
+
+ suite_key = suite_category.to_s.downcase.strip
+ suite_key = ActiveSupport::Inflector.transliterate(suite_key)
+
+ valid_terms = aliases[suite_key] || [suite_key]
+
+ match_found = valid_terms.any? do |term|
+ term_clean = ActiveSupport::Inflector.transliterate(term)
+ user_text_post_reset.include?(term_clean)
+ end
+
+ Rails.logger.debug { "[CreateReservationIntentTool] Intent barrier: #{valid_terms} in '#{user_text_post_reset}' -> Match: #{match_found}" }
+
+ unless match_found
+ Rails.logger.info "[CreateReservationIntentTool] Intent blocked: Suite '#{suite_category}' not found after reset"
+ return "Atenção: O usuário ainda não escolheu a suíte '#{suite_category}' nesta nova conversa. Pergunte: 'Qual suíte você gostaria de reservar?'."
+ end
+
+ nil
+ end
+
+ def resolve_check_in_and_out(params)
+ c_in = params[:check_in_at] || params[:date] || params[:day]
+ c_out = params[:check_out_at]
+ dur = params[:duration_hours]&.to_f
+
+ check_in = parse_flexible_datetime(c_in) || Time.zone.now.tomorrow.change(hour: 14)
+
+ check_out = if c_out.present?
+ parse_flexible_datetime(c_out) || (check_in + 3.hours)
+ elsif dur&.positive?
+ check_in + dur.hours
+ else
+ check_in + 3.hours
+ end
+
+ [check_in, check_out]
+ end
+
+ # Interpreta múltiplos formatos de data/hora:
+ # - "HH:MM" → hoje nesse horário
+ # - "DD/MM/YYYY HH:MM" → data + hora
+ # - ISO8601 → parse direto
+ def parse_flexible_datetime(value)
+ return nil if value.blank?
+
+ str = value.to_s.strip
+
+ # Formato "HH:MM" → hoje nesse horário
+ if str.match?(/\A\d{1,2}:\d{2}\z/)
+ hour, min = str.split(':').map(&:to_i)
+ return Time.zone.now.change(hour: hour, min: min, sec: 0)
+ end
+
+ # Formato "DD/MM/YYYY HH:MM" ou "DD/MM/YYYY"
+ if str.match?(%r{\A\d{1,2}/\d{1,2}/\d{2,4}})
+ normalized = str.gsub(%r{(\d{1,2})/(\d{1,2})/(\d{2,4})}) do
+ "#{Regexp.last_match(3)}-#{Regexp.last_match(2).rjust(2, '0')}-#{Regexp.last_match(1).rjust(2, '0')}"
+ end
+ return Time.zone.parse(normalized)
+ end
+
+ Time.zone.parse(str)
+ rescue ArgumentError, TypeError
+ nil
+ end
+
+ def price_mismatch?(price_a, price_b)
+ (price_a.to_f - price_b.to_f).abs > 0.01
+ end
+
+ def ensure_conversation_context!
+ return if @conversation.present?
+ end
+
+ def infer_unit
+ @conversation&.inbox&.captain_inbox&.unit
+ end
+
+ def update_sticky_state(suite:, price:, check_in_at:, check_out_at:)
+ return unless @conversation.respond_to?(:active_scenario_state)
+
+ state = @conversation.active_scenario_state || {}
+ collected = (state['collected'] || {}).merge(
+ 'suite' => suite,
+ 'price' => price,
+ 'check_in_at' => check_in_at&.iso8601,
+ 'check_out_at' => check_out_at&.iso8601
+ ).compact
+
+ @conversation.update!(
+ active_scenario_state: state.merge(
+ 'stage' => 'reservation_intent_created',
+ 'collected' => collected,
+ 'updated_at' => Time.current.iso8601
+ )
+ )
+ rescue StandardError => e
+ Rails.logger.warn "[CreateReservationIntentTool] Failed to update sticky state: #{e.message}"
+ end
+
+ def fetch_last_availability
+ return nil unless @conversation
+
+ data = @conversation.custom_attributes&.fetch('last_availability', nil)
+ return nil unless data.is_a?(Hash)
+
+ captured_at = data['captured_at']
+ return nil if captured_at.blank?
+
+ if Time.zone.parse(captured_at) < 4.hours.ago
+ Rails.logger.info '[CreateReservationIntent] Ignorando last_availability expirado (older than 4h)'
+ return nil
+ end
+
+ data.with_indifferent_access
+ end
+
+ # Complexity is inherent: branches for reset detection + suite/price inference across messages
+ def infer_from_history
+ return {} if @conversation.blank?
+
+ suite_candidates = available_suite_categories
+
+ messages = @conversation.messages
+ .where(private: false)
+ .where('created_at >= ?', 4.hours.ago)
+ .order(created_at: :desc)
+ .limit(20).to_a
+
+ reset_msg = messages.find { |m| m.content.to_s.downcase.match?(/\b(reiniciar|resetar|comecar de novo)\b/i) }
+ messages = messages.take_while { |m| m.id != reset_msg.id } if reset_msg
+
+ messages.each do |message|
+ content = message.content.to_s
+ suite = find_suite_in_text(content, suite_candidates)
+ price = extract_price_from_text(content)
+
+ return { suite: suite, price: price } if suite.present? || price.present?
+ end
+
+ {}
+ end
+
+ def available_suite_categories
+ unit = infer_unit
+ return %w[Stilo Master Hidromassagem] unless unit
+
+ Captain::Pricing.where(captain_brand_id: unit.captain_brand_id).pluck(:suite_category).compact.uniq
+ end
+
+ def find_suite_in_text(content, suite_candidates)
+ return nil if content.blank?
+
+ suite_candidates.find { |suite| content.downcase.include?(suite.to_s.downcase) }
+ end
+
+ def extract_price_from_text(content)
+ return nil if content.blank?
+
+ match = content.match(/R\$\s*([\d\.]+,\d{2})/)
+ return nil unless match
+
+ match[1].tr('.', '').tr(',', '.').to_f
+ end
+end
+# rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize
+# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Layout/LineLength
+# rubocop:enable Rails/SkipsModelValidations