feat(captain): engine column + DB-driven Hermes routing + Express pricing
Marca cada Captain::Assistant com engine ('captain_interno' | 'hermes')
e move o roteamento Hermes do env var pro banco — admin troca engine
re-apontando a inbox no painel, sem deploy. Mantém fallback pras env
vars antigas (CAPTAIN_HERMES_INBOX_IDS etc) durante a migração gradual,
pra não quebrar Valentina antes da re-associação.
Frontend: badge "Hermes" (âmbar) ou "Interno" (cinza) ao lado de cada
assistant no dropdown switcher e no card da listagem, com chaves i18n
em en + pt_BR.
Tabela de preço (pricing_tables.rb): adiciona unit Express (id=5) e
estende a estrutura pra aceitar preço por dia da semana
(mon_wed/thu_sun) — necessário pro Express, retrocompatível com Dolce
Amore (preço único).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5f6aed05c9
commit
3182002bd9
@ -26,6 +26,10 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
engine: {
|
||||||
|
type: String,
|
||||||
|
default: 'captain_interno',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['action']);
|
const emit = defineEmits(['action']);
|
||||||
@ -76,11 +80,27 @@ const handleAction = ({ action, value }) => {
|
|||||||
<template>
|
<template>
|
||||||
<CardLayout>
|
<CardLayout>
|
||||||
<div class="flex justify-between w-full gap-1">
|
<div class="flex justify-between w-full gap-1">
|
||||||
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<h6
|
<h6
|
||||||
class="text-base font-normal text-n-slate-12 line-clamp-1 hover:underline transition-colors"
|
class="text-base font-normal text-n-slate-12 line-clamp-1 hover:underline transition-colors"
|
||||||
>
|
>
|
||||||
{{ name }}
|
{{ name }}
|
||||||
</h6>
|
</h6>
|
||||||
|
<span
|
||||||
|
v-if="engine === 'hermes'"
|
||||||
|
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-n-amber-3 text-n-amber-11 shrink-0"
|
||||||
|
:title="t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_HERMES_TOOLTIP')"
|
||||||
|
>
|
||||||
|
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_HERMES') }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-n-slate-3 text-n-slate-11 shrink-0"
|
||||||
|
:title="t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_INTERNO_TOOLTIP')"
|
||||||
|
>
|
||||||
|
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_INTERNO') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
v-on-clickaway="() => toggleDropdown(false)"
|
v-on-clickaway="() => toggleDropdown(false)"
|
||||||
|
|||||||
@ -130,6 +130,20 @@ const openCreateAssistantDialog = () => {
|
|||||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||||
{{ assistant.name || '' }}
|
{{ assistant.name || '' }}
|
||||||
</span>
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="assistant.engine === 'hermes'"
|
||||||
|
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-n-amber-3 text-n-amber-11"
|
||||||
|
:title="t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_HERMES_TOOLTIP')"
|
||||||
|
>
|
||||||
|
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_HERMES') }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else
|
||||||
|
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-n-slate-3 text-n-slate-11"
|
||||||
|
:title="t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_INTERNO_TOOLTIP')"
|
||||||
|
>
|
||||||
|
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_INTERNO') }}
|
||||||
|
</span>
|
||||||
<Avatar
|
<Avatar
|
||||||
v-if="assistant"
|
v-if="assistant"
|
||||||
:name="assistant.name"
|
:name="assistant.name"
|
||||||
|
|||||||
@ -385,7 +385,11 @@
|
|||||||
"ASSISTANTS": "Assistants",
|
"ASSISTANTS": "Assistants",
|
||||||
"SWITCH_ASSISTANT": "Switch between assistants",
|
"SWITCH_ASSISTANT": "Switch between assistants",
|
||||||
"NEW_ASSISTANT": "Create Assistant",
|
"NEW_ASSISTANT": "Create Assistant",
|
||||||
"EMPTY_LIST": "No assistants found, please create one to get started"
|
"EMPTY_LIST": "No assistants found, please create one to get started",
|
||||||
|
"ENGINE_HERMES": "Hermes",
|
||||||
|
"ENGINE_HERMES_TOOLTIP": "Assistant operated by the Hermes Agent (external LLM)",
|
||||||
|
"ENGINE_INTERNO": "Internal",
|
||||||
|
"ENGINE_INTERNO_TOOLTIP": "Assistant operated by the internal Captain orchestrator"
|
||||||
},
|
},
|
||||||
"COPILOT": {
|
"COPILOT": {
|
||||||
"TITLE": "Copilot",
|
"TITLE": "Copilot",
|
||||||
|
|||||||
@ -366,7 +366,11 @@
|
|||||||
"ASSISTANTS": "Assistentes",
|
"ASSISTANTS": "Assistentes",
|
||||||
"SWITCH_ASSISTANT": "Alternar entre assistentes",
|
"SWITCH_ASSISTANT": "Alternar entre assistentes",
|
||||||
"NEW_ASSISTANT": "Criar Assistente",
|
"NEW_ASSISTANT": "Criar Assistente",
|
||||||
"EMPTY_LIST": "Nenhum assistente encontrado, crie um para começar"
|
"EMPTY_LIST": "Nenhum assistente encontrado, crie um para começar",
|
||||||
|
"ENGINE_HERMES": "Hermes",
|
||||||
|
"ENGINE_HERMES_TOOLTIP": "Atendente operada pelo Hermes Agent (LLM externo)",
|
||||||
|
"ENGINE_INTERNO": "Interno",
|
||||||
|
"ENGINE_INTERNO_TOOLTIP": "Atendente operada pelo orquestrador interno do Captain"
|
||||||
},
|
},
|
||||||
"COPILOT": {
|
"COPILOT": {
|
||||||
"TITLE": "Copiloto",
|
"TITLE": "Copiloto",
|
||||||
|
|||||||
@ -0,0 +1,8 @@
|
|||||||
|
class AddEngineToCaptainAssistants < ActiveRecord::Migration[7.1]
|
||||||
|
def change
|
||||||
|
add_column :captain_assistants, :engine, :string, default: 'captain_interno', null: false
|
||||||
|
add_column :captain_assistants, :hermes_profile_name, :string
|
||||||
|
add_column :captain_assistants, :hermes_webhook_base_url, :string
|
||||||
|
add_index :captain_assistants, :engine
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -49,6 +49,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
|||||||
|
|
||||||
def assistant_params
|
def assistant_params
|
||||||
permitted = params.require(:assistant).permit(:name, :description, :orchestrator_prompt,
|
permitted = params.require(:assistant).permit(:name, :description, :orchestrator_prompt,
|
||||||
|
:engine, :hermes_profile_name, :hermes_webhook_base_url,
|
||||||
config: [
|
config: [
|
||||||
:product_name, :feature_faq, :feature_memory, :feature_citation,
|
:product_name, :feature_faq, :feature_memory, :feature_citation,
|
||||||
:welcome_message, :handoff_message, :resolution_message,
|
:welcome_message, :handoff_message, :resolution_message,
|
||||||
|
|||||||
@ -43,9 +43,17 @@ class Captain::Assistant < ApplicationRecord
|
|||||||
|
|
||||||
store_accessor :config, :temperature, :feature_faq, :feature_memory, :product_name
|
store_accessor :config, :temperature, :feature_faq, :feature_memory, :product_name
|
||||||
|
|
||||||
|
ENGINES = %w[captain_interno hermes].freeze
|
||||||
|
|
||||||
validates :name, presence: true
|
validates :name, presence: true
|
||||||
validates :description, presence: true
|
validates :description, presence: true
|
||||||
validates :account_id, presence: true
|
validates :account_id, presence: true
|
||||||
|
validates :engine, inclusion: { in: ENGINES }
|
||||||
|
validates :hermes_profile_name, presence: true, if: :hermes?
|
||||||
|
validates :hermes_webhook_base_url, presence: true, if: :hermes?
|
||||||
|
|
||||||
|
scope :hermes, -> { where(engine: 'hermes') }
|
||||||
|
scope :captain_interno, -> { where(engine: 'captain_interno') }
|
||||||
|
|
||||||
scope :ordered, -> { order(created_at: :desc) }
|
scope :ordered, -> { order(created_at: :desc) }
|
||||||
|
|
||||||
@ -55,6 +63,14 @@ class Captain::Assistant < ApplicationRecord
|
|||||||
name
|
name
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def hermes?
|
||||||
|
engine == 'hermes'
|
||||||
|
end
|
||||||
|
|
||||||
|
def captain_interno?
|
||||||
|
engine == 'captain_interno'
|
||||||
|
end
|
||||||
|
|
||||||
def available_agent_tools
|
def available_agent_tools
|
||||||
tools = self.class.built_in_agent_tools.dup
|
tools = self.class.built_in_agent_tools.dup
|
||||||
|
|
||||||
|
|||||||
@ -7,24 +7,32 @@
|
|||||||
# - Hermes invoca plugin captain-http-callback que POSTa de volta no Captain
|
# - Hermes invoca plugin captain-http-callback que POSTa de volta no Captain
|
||||||
# - Captain cria mensagem outgoing e envia pro WhatsApp
|
# - Captain cria mensagem outgoing e envia pro WhatsApp
|
||||||
#
|
#
|
||||||
# A ativação é por inbox via env var. As 9 outras inboxes do Captain seguem
|
# A ativação preferencial é DATA-DRIVEN: cada Captain::Assistant tem coluna
|
||||||
# usando o orquestrador interno (Daniela_Reservas, etc) sem mudança.
|
# `engine` ('captain_interno' | 'hermes'). Inboxes apontam pra um assistant
|
||||||
|
# via CaptainInbox; o engine do assistant determina o roteamento. Trocar de
|
||||||
|
# engine = trocar a association no painel, sem deploy.
|
||||||
#
|
#
|
||||||
# Env vars:
|
# Por compatibilidade durante a migração (gradual), também respeitamos as
|
||||||
# CAPTAIN_HERMES_INBOX_IDS CSV de inbox.id (ex: "1,5"). Se vazio,
|
# env vars antigas: se uma inbox está em CAPTAIN_HERMES_INBOX_IDS mas o
|
||||||
# desativa em todas. Inboxes não listadas
|
# assistant ainda é 'captain_interno', tratamos como Hermes — assim Valentina
|
||||||
# continuam no fluxo Captain interno.
|
# continua funcionando antes do admin re-apontar a inbox no painel.
|
||||||
# CAPTAIN_HERMES_WEBHOOK_BASE_URL Base URL do gateway Hermes
|
# Esse fallback deve ser removido depois que todos as inboxes migrarem.
|
||||||
# (default http://172.17.0.1:8644).
|
#
|
||||||
# CAPTAIN_HERMES_CALLBACK_SECRET HMAC-SHA256 secret pra validar callback
|
# Env vars (apenas credenciais — config funcional vive no DB):
|
||||||
# do Hermes (X-Hermes-Callback-Signature).
|
# CAPTAIN_HERMES_CALLBACK_SECRET HMAC-SHA256 secret pra validar
|
||||||
# Se vazio, validação é desabilitada (NÃO
|
# callback do Hermes
|
||||||
# recomendado em prod).
|
# (X-Hermes-Callback-Signature).
|
||||||
# CAPTAIN_HERMES_SUBSCRIPTION_SECRET_INBOX_<id>
|
# CAPTAIN_HERMES_SUBSCRIPTION_SECRET_INBOX_<id>
|
||||||
# Per-inbox secret retornado pelo
|
# Per-inbox secret retornado pelo
|
||||||
# `hermes webhook subscribe`. Usado pra
|
# `hermes webhook subscribe`. Usado
|
||||||
# assinar o POST OUTGOING. Sem ele, o
|
# pra assinar o POST OUTGOING.
|
||||||
# Hermes vai rejeitar o webhook.
|
#
|
||||||
|
# Env vars LEGACY (em descontinuação — preferir DB):
|
||||||
|
# CAPTAIN_HERMES_INBOX_IDS CSV de inbox.id. Usado só como
|
||||||
|
# fallback até as inboxes terem
|
||||||
|
# assistant com engine='hermes'.
|
||||||
|
# CAPTAIN_HERMES_WEBHOOK_BASE_URL Base URL default. Idem.
|
||||||
|
# CAPTAIN_HERMES_BASE_URL_INBOX_<id> Per-inbox base URL. Idem.
|
||||||
module Captain::Hermes
|
module Captain::Hermes
|
||||||
DEFAULT_BASE_URL = 'http://172.17.0.1:8644'.freeze
|
DEFAULT_BASE_URL = 'http://172.17.0.1:8644'.freeze
|
||||||
|
|
||||||
@ -34,23 +42,27 @@ module Captain::Hermes
|
|||||||
return false if inbox.blank?
|
return false if inbox.blank?
|
||||||
return false unless inbox.respond_to?(:id)
|
return false unless inbox.respond_to?(:id)
|
||||||
|
|
||||||
inbox_ids.include?(inbox.id)
|
return true if assistant_for(inbox)&.hermes?
|
||||||
|
|
||||||
|
legacy_inbox_ids.include?(inbox.id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def inbox_ids
|
def assistant_for(inbox)
|
||||||
@inbox_ids ||= ENV.fetch('CAPTAIN_HERMES_INBOX_IDS', '')
|
return nil if inbox.blank?
|
||||||
.split(',')
|
return nil unless inbox.respond_to?(:captain_inbox)
|
||||||
.map { |s| s.strip.to_i }
|
|
||||||
.reject(&:zero?)
|
inbox.captain_inbox&.captain_assistant
|
||||||
.freeze
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def webhook_base_url
|
def webhook_base_url(inbox = nil)
|
||||||
@webhook_base_url ||= (ENV['CAPTAIN_HERMES_WEBHOOK_BASE_URL'].presence || DEFAULT_BASE_URL).chomp('/')
|
assistant = assistant_for(inbox)
|
||||||
|
return assistant.hermes_webhook_base_url.chomp('/') if assistant&.hermes? && assistant.hermes_webhook_base_url.present?
|
||||||
|
|
||||||
|
legacy_webhook_base_url(inbox)
|
||||||
end
|
end
|
||||||
|
|
||||||
def webhook_url_for(inbox)
|
def webhook_url_for(inbox)
|
||||||
"#{webhook_base_url}/webhooks/#{subscription_name_for(inbox)}"
|
"#{webhook_base_url(inbox)}/webhooks/#{subscription_name_for(inbox)}"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Convenção de nome de subscription no Hermes: precisa bater com o que o
|
# Convenção de nome de subscription no Hermes: precisa bater com o que o
|
||||||
@ -67,9 +79,25 @@ module Captain::Hermes
|
|||||||
ENV.fetch('CAPTAIN_HERMES_CALLBACK_SECRET', nil)
|
ENV.fetch('CAPTAIN_HERMES_CALLBACK_SECRET', nil)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Reseta caches. Útil em specs ou após reload de config.
|
|
||||||
def reset_cache!
|
def reset_cache!
|
||||||
@inbox_ids = nil
|
@legacy_inbox_ids = nil
|
||||||
@webhook_base_url = nil
|
end
|
||||||
|
|
||||||
|
# === Legacy (env var) fallbacks ===
|
||||||
|
|
||||||
|
def legacy_inbox_ids
|
||||||
|
@legacy_inbox_ids ||= ENV.fetch('CAPTAIN_HERMES_INBOX_IDS', '')
|
||||||
|
.split(',')
|
||||||
|
.map { |s| s.strip.to_i }
|
||||||
|
.reject(&:zero?)
|
||||||
|
.freeze
|
||||||
|
end
|
||||||
|
|
||||||
|
def legacy_webhook_base_url(inbox = nil)
|
||||||
|
if inbox && (per_inbox = ENV.fetch("CAPTAIN_HERMES_BASE_URL_INBOX_#{inbox.id}", nil)).present?
|
||||||
|
return per_inbox.chomp('/')
|
||||||
|
end
|
||||||
|
|
||||||
|
(ENV['CAPTAIN_HERMES_WEBHOOK_BASE_URL'].presence || DEFAULT_BASE_URL).chomp('/')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@ -9,19 +9,25 @@
|
|||||||
# Estrutura: TABLES[captain_unit_id] = {
|
# Estrutura: TABLES[captain_unit_id] = {
|
||||||
# categories: {
|
# categories: {
|
||||||
# '<categoria_key>' => {
|
# '<categoria_key>' => {
|
||||||
# prices: { '3h' => 85, 'pernoite_promo' => 110, ... },
|
# # Preço por período. Aceita 2 formatos:
|
||||||
# aliases: ['apto', 'standard', 'apartamento standard', ...]
|
# # 1) Valor único (motel sem variação por dia da semana):
|
||||||
|
# # prices: { '3h' => 85.0, 'pernoite_promo' => 110.0 }
|
||||||
|
# # 2) Hash por bucket de dia da semana (hotel: qui-dom caro):
|
||||||
|
# # prices: { '3h' => { 'mon_wed' => 50.0, 'thu_sun' => 65.0 } }
|
||||||
|
# # Buckets aceitos: 'mon_wed' (seg-qua) e 'thu_sun' (qui-dom).
|
||||||
|
# prices: { '3h' => 85, ... },
|
||||||
|
# aliases: ['apto', 'standard', ...]
|
||||||
# }
|
# }
|
||||||
# },
|
# },
|
||||||
# extra_person_fee: 45,
|
# extra_person_fee: 45,
|
||||||
# extra_person_rules: { '<categoria_key>' => starts_at_guest_n }
|
# extra_person_rules: { '<categoria_key>' => starts_at_guest_n }
|
||||||
# }
|
# }
|
||||||
#
|
|
||||||
# Hoje só Dolce Amore (unit 4) está mapeado — Hermes só está ativo nele.
|
|
||||||
# Conforme outras unidades migrarem pra Hermes, expandir aqui.
|
|
||||||
# rubocop:disable Metrics/ModuleLength
|
# rubocop:disable Metrics/ModuleLength
|
||||||
module Captain::Mcp::PricingTables
|
module Captain::Mcp::PricingTables
|
||||||
PERIOD_KEYS = %w[3h pernoite_promo pernoite_integral diaria].freeze
|
PERIOD_KEYS = %w[2h 3h 4h 5h pernoite_promo pernoite_integral diaria].freeze
|
||||||
|
# mon_wed cobre wday 1,2,3 (seg-qua); thu_sun cobre wday 4,5,6,0 (qui-dom).
|
||||||
|
DAY_BUCKETS = %w[mon_wed thu_sun].freeze
|
||||||
|
DEFAULT_TZ = 'America/Sao_Paulo'.freeze
|
||||||
|
|
||||||
TABLES = {
|
TABLES = {
|
||||||
# Motel Dolce Amore — Ponta Negra, Natal/RN (captain_unit_id=4)
|
# Motel Dolce Amore — Ponta Negra, Natal/RN (captain_unit_id=4)
|
||||||
@ -73,16 +79,74 @@ module Captain::Mcp::PricingTables
|
|||||||
aliases: ['chale master', 'chalé master', 'master 4 suites', 'chalé master 4 suítes', 'chale_master', '4 suites']
|
aliases: ['chale master', 'chalé master', 'master 4 suites', 'chalé master 4 suítes', 'chale_master', '4 suites']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
# Hotel 1001 Noites Express — Águas Lindas/GO (captain_unit_id=5)
|
||||||
|
# Preço varia por dia da semana (mon_wed = seg-qua / thu_sun = qui-dom).
|
||||||
|
# Diária e Família são flat (mesmo preço todos os dias).
|
||||||
|
5 => {
|
||||||
|
currency: 'BRL',
|
||||||
|
extra_person_fee: 0.0,
|
||||||
|
categories: {
|
||||||
|
'standard' => {
|
||||||
|
prices: {
|
||||||
|
'2h' => { 'mon_wed' => 40.0, 'thu_sun' => 50.0 },
|
||||||
|
'3h' => { 'mon_wed' => 50.0, 'thu_sun' => 65.0 },
|
||||||
|
'4h' => { 'mon_wed' => 60.0, 'thu_sun' => 80.0 },
|
||||||
|
'pernoite_promo' => { 'mon_wed' => 100.0, 'thu_sun' => 120.0 },
|
||||||
|
'diaria' => 150.0
|
||||||
|
},
|
||||||
|
extra_person_starts_at: 3,
|
||||||
|
aliases: ['standard', 'comum', 'básica', 'basica', 'apartamento standard']
|
||||||
|
},
|
||||||
|
'master' => {
|
||||||
|
prices: {
|
||||||
|
'2h' => { 'mon_wed' => 50.0, 'thu_sun' => 60.0 },
|
||||||
|
'3h' => { 'mon_wed' => 60.0, 'thu_sun' => 75.0 },
|
||||||
|
'4h' => { 'mon_wed' => 70.0 },
|
||||||
|
'5h' => { 'thu_sun' => 85.0 },
|
||||||
|
'pernoite_promo' => { 'mon_wed' => 120.0, 'thu_sun' => 140.0 },
|
||||||
|
'diaria' => 160.0
|
||||||
|
},
|
||||||
|
extra_person_starts_at: 3,
|
||||||
|
aliases: ['master', 'melhor', 'suite master', 'suíte master']
|
||||||
|
},
|
||||||
|
'singles' => {
|
||||||
|
prices: {
|
||||||
|
'pernoite_promo' => { 'mon_wed' => 80.0, 'thu_sun' => 110.0 },
|
||||||
|
'diaria' => 130.0
|
||||||
|
},
|
||||||
|
extra_person_starts_at: 99,
|
||||||
|
aliases: %w[singles single sozinho]
|
||||||
|
},
|
||||||
|
'familia' => {
|
||||||
|
prices: {
|
||||||
|
'pernoite_promo' => 160.0,
|
||||||
|
'diaria' => 190.0
|
||||||
|
},
|
||||||
|
extra_person_starts_at: 99,
|
||||||
|
aliases: %w[familia família familiar]
|
||||||
|
},
|
||||||
|
'singles_duplo' => {
|
||||||
|
prices: {
|
||||||
|
'pernoite_promo' => { 'mon_wed' => 180.0, 'thu_sun' => 220.0 },
|
||||||
|
'diaria' => 250.0
|
||||||
|
},
|
||||||
|
extra_person_starts_at: 99,
|
||||||
|
aliases: ['singles duplo', 'singles_duplo', 'casal', 'duplo']
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
# Retorna {amount:, breakdown:} ou erro {error:} pra uma cobrança.
|
# Retorna {amount:, breakdown:} ou erro {error:} pra uma cobrança.
|
||||||
# period: '3h' | 'pernoite_promo' | 'pernoite_integral' | 'diaria'
|
# period: '3h' | 'pernoite_promo' | 'pernoite_integral' | 'diaria'
|
||||||
# extra_guests: número TOTAL de hóspedes (não só os "extras" — a função
|
# check_in_at: Time/String ISO8601. Determina o bucket de dia da semana
|
||||||
# calcula extras baseado em extra_person_starts_at).
|
# quando o preço varia (mon_wed/thu_sun). Default: agora.
|
||||||
# rubocop:disable Metrics/MethodLength
|
# total_guests: número TOTAL de hóspedes — a função calcula extras
|
||||||
def calculate(unit_id:, suite_category:, period:, total_guests: 2)
|
# baseado em extra_person_starts_at.
|
||||||
|
# rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
||||||
|
def calculate(unit_id:, suite_category:, period:, total_guests: 2, check_in_at: nil)
|
||||||
table = TABLES[unit_id]
|
table = TABLES[unit_id]
|
||||||
return { error: "Unidade #{unit_id} não tem tabela de preços cadastrada." } if table.blank?
|
return { error: "Unidade #{unit_id} não tem tabela de preços cadastrada." } if table.blank?
|
||||||
|
|
||||||
@ -92,8 +156,12 @@ module Captain::Mcp::PricingTables
|
|||||||
period_key = normalize_period(period)
|
period_key = normalize_period(period)
|
||||||
return { error: "Período '#{period}' inválido. Use: #{PERIOD_KEYS.join(', ')}." } if period_key.blank?
|
return { error: "Período '#{period}' inválido. Use: #{PERIOD_KEYS.join(', ')}." } if period_key.blank?
|
||||||
|
|
||||||
base = cat_data[:prices][period_key]
|
raw = cat_data[:prices][period_key]
|
||||||
return { error: "Preço de '#{period_key}' não definido para '#{cat_key}'." } if base.blank?
|
return { error: "Preço de '#{period_key}' não definido para '#{cat_key}'." } if raw.blank?
|
||||||
|
|
||||||
|
day_bucket = resolve_day_bucket(check_in_at)
|
||||||
|
base, used_bucket = resolve_price(raw, day_bucket, period_key, cat_key)
|
||||||
|
return { error: base } if base.is_a?(String)
|
||||||
|
|
||||||
starts_at = cat_data[:extra_person_starts_at] || 3
|
starts_at = cat_data[:extra_person_starts_at] || 3
|
||||||
extra_guests = [total_guests.to_i - (starts_at - 1), 0].max
|
extra_guests = [total_guests.to_i - (starts_at - 1), 0].max
|
||||||
@ -106,6 +174,7 @@ module Captain::Mcp::PricingTables
|
|||||||
unit_id: unit_id,
|
unit_id: unit_id,
|
||||||
suite_category: cat_key,
|
suite_category: cat_key,
|
||||||
period: period_key,
|
period: period_key,
|
||||||
|
day_bucket: used_bucket,
|
||||||
base_price: base,
|
base_price: base,
|
||||||
total_guests: total_guests,
|
total_guests: total_guests,
|
||||||
extra_guests: extra_guests,
|
extra_guests: extra_guests,
|
||||||
@ -121,6 +190,35 @@ module Captain::Mcp::PricingTables
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
# Recebe Numeric (preço único) ou Hash{bucket=>preço}. Retorna [valor, bucket]
|
||||||
|
# ou [erro_string, nil] se o bucket pedido não tiver preço cadastrado.
|
||||||
|
def resolve_price(raw, day_bucket, period_key, cat_key)
|
||||||
|
return [raw.to_f, nil] if raw.is_a?(Numeric)
|
||||||
|
|
||||||
|
return ["Estrutura de preço inválida pra '#{cat_key}/#{period_key}'.", nil] unless raw.is_a?(Hash)
|
||||||
|
|
||||||
|
price = raw[day_bucket] || raw[day_bucket.to_s]
|
||||||
|
if price.blank?
|
||||||
|
avail = raw.keys.map(&:to_s).join(', ')
|
||||||
|
return ["'#{cat_key}/#{period_key}' não tem preço pro dia escolhido (#{day_bucket}). Disponível: #{avail}.", nil]
|
||||||
|
end
|
||||||
|
|
||||||
|
[price.to_f, day_bucket]
|
||||||
|
end
|
||||||
|
|
||||||
|
# mon_wed: wday 1,2,3 (seg, ter, qua)
|
||||||
|
# thu_sun: wday 4,5,6,0 (qui, sex, sáb, dom)
|
||||||
|
def resolve_day_bucket(check_in_at)
|
||||||
|
time =
|
||||||
|
case check_in_at
|
||||||
|
when nil then Time.current.in_time_zone(DEFAULT_TZ)
|
||||||
|
when Time, ActiveSupport::TimeWithZone, DateTime then check_in_at.in_time_zone(DEFAULT_TZ)
|
||||||
|
else Time.zone.parse(check_in_at.to_s)&.in_time_zone(DEFAULT_TZ) || Time.current.in_time_zone(DEFAULT_TZ)
|
||||||
|
end
|
||||||
|
|
||||||
|
[1, 2, 3].include?(time.wday) ? 'mon_wed' : 'thu_sun'
|
||||||
|
end
|
||||||
|
|
||||||
def find_category(table, raw)
|
def find_category(table, raw)
|
||||||
needle = raw.to_s.downcase.strip.tr('_', ' ').squeeze(' ')
|
needle = raw.to_s.downcase.strip.tr('_', ' ').squeeze(' ')
|
||||||
return [nil, nil] if needle.blank?
|
return [nil, nil] if needle.blank?
|
||||||
@ -145,7 +243,7 @@ module Captain::Mcp::PricingTables
|
|||||||
when 'diária' then 'diaria'
|
when 'diária' then 'diaria'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
# rubocop:enable Metrics/MethodLength
|
# rubocop:enable Metrics/MethodLength,Metrics/AbcSize
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
# rubocop:enable Metrics/ModuleLength
|
# rubocop:enable Metrics/ModuleLength
|
||||||
|
|||||||
@ -3,7 +3,10 @@ json.config resource.config
|
|||||||
json.created_at resource.created_at.to_i
|
json.created_at resource.created_at.to_i
|
||||||
json.default_orchestrator_prompt Captain::PromptRenderer.read_template('assistant')
|
json.default_orchestrator_prompt Captain::PromptRenderer.read_template('assistant')
|
||||||
json.description resource.description
|
json.description resource.description
|
||||||
|
json.engine resource.engine
|
||||||
json.guardrails resource.guardrails
|
json.guardrails resource.guardrails
|
||||||
|
json.hermes_profile_name resource.hermes_profile_name
|
||||||
|
json.hermes_webhook_base_url resource.hermes_webhook_base_url
|
||||||
json.id resource.id
|
json.id resource.id
|
||||||
json.name resource.name
|
json.name resource.name
|
||||||
json.orchestrator_prompt resource.orchestrator_prompt
|
json.orchestrator_prompt resource.orchestrator_prompt
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user