Migra a tabela de preços do PricingTables.rb hardcoded pras tabelas captain_pricing_categories + captain_pricing_amounts no DB. Mantém a mesma API pública Captain::Mcp::PricingTables.calculate(...) — código chama o banco via novos modelos Captain::PricingCategory e Captain::PricingAmount. Seed db/seed_pricing_tables.rb faz backfill idempotente pra Dolce Amore (unit 4) e Express (unit 5) com a mesma estrutura que tava no Ruby. Adiciona em captain_assistants: - hermes_subscription_secret (gerado pelo script de provisionamento) - hermes_port (alocado no range 8650-8699) - parent_assistant_id (link informativo Hermes → captain_interno parent pra sombrear FAQs/scenarios via header X-Captain-Assistant-Id) Adiciona em captain_units: extra_person_fee + currency. Primeiro milestone do roadmap arquitetural pro Construtor autônomo (decisões em memory/project_construtor_autonomo_decisions.md). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
112 lines
4.4 KiB
Ruby
112 lines
4.4 KiB
Ruby
# Tabelas de preço por unidade do Captain — fonte de verdade backend pra
|
|
# tools MCP que precisam validar valor.
|
|
#
|
|
# Quando o LLM chama `generate_pix`, ele NÃO informa o valor; apenas
|
|
# categoria/período/data. Tool calcula via essa tabela no banco. Isso
|
|
# impede que o LLM invente um valor.
|
|
#
|
|
# Fonte de dados: tabelas Captain::PricingCategory + Captain::PricingAmount,
|
|
# escritas pelo admin via UI ou pelo Construtor via script `hermes-provision`.
|
|
# Antes de v147 esses dados viviam num arquivo Ruby hardcoded — migrado pra
|
|
# DB pra permitir cadastro dinâmico de unidades sem deploy.
|
|
module Captain::Mcp::PricingTables
|
|
PERIOD_KEYS = %w[2h 3h 4h 5h pernoite_promo pernoite_integral diaria].freeze
|
|
DAY_BUCKETS = %w[mon_wed thu_sun].freeze
|
|
DEFAULT_TZ = 'America/Sao_Paulo'.freeze
|
|
|
|
class << self
|
|
# Retorna {amount:, breakdown:} ou erro {error:} pra uma cobrança.
|
|
# rubocop:disable Metrics/MethodLength,Metrics/AbcSize
|
|
def calculate(unit_id:, suite_category:, period:, total_guests: 2, check_in_at: nil)
|
|
unit = Captain::Unit.find_by(id: unit_id)
|
|
return { error: "Unidade #{unit_id} não cadastrada." } if unit.blank?
|
|
|
|
category = find_category(unit, suite_category)
|
|
return { error: "Categoria '#{suite_category}' não reconhecida nesta unidade." } if category.blank?
|
|
|
|
period_key = normalize_period(period)
|
|
return { error: "Período '#{period}' inválido. Use: #{PERIOD_KEYS.join(', ')}." } if period_key.blank?
|
|
|
|
day_bucket = resolve_day_bucket(check_in_at)
|
|
base, used_bucket = resolve_amount(category, period_key, day_bucket)
|
|
return { error: base } if base.is_a?(String)
|
|
|
|
starts_at = category.extra_person_starts_at || 3
|
|
extra_guests = [total_guests.to_i - (starts_at - 1), 0].max
|
|
extra_total = extra_guests * unit.extra_person_fee.to_f
|
|
total = (base + extra_total).round(2)
|
|
|
|
{
|
|
amount: total,
|
|
breakdown: {
|
|
unit_id: unit.id,
|
|
suite_category: category.key,
|
|
period: period_key,
|
|
day_bucket: used_bucket,
|
|
base_price: base,
|
|
total_guests: total_guests,
|
|
extra_guests: extra_guests,
|
|
extra_person_fee: unit.extra_person_fee.to_f,
|
|
extra_total: extra_total
|
|
}
|
|
}
|
|
end
|
|
# rubocop:enable Metrics/MethodLength,Metrics/AbcSize
|
|
|
|
def categories_for(unit_id)
|
|
Captain::PricingCategory.where(captain_unit_id: unit_id).order(:key).pluck(:key)
|
|
end
|
|
|
|
private
|
|
|
|
def find_category(unit, raw)
|
|
return nil if raw.blank?
|
|
|
|
needle = raw.to_s.downcase.strip.tr('_', ' ').squeeze(' ')
|
|
unit.pricing_categories.find { |cat| cat.matches?(needle) }
|
|
end
|
|
|
|
def resolve_amount(category, period_key, day_bucket)
|
|
amounts = category.amounts.where(period: period_key)
|
|
return ["Preço de '#{period_key}' não definido para '#{category.key}'.", nil] if amounts.empty?
|
|
|
|
# 1) Tenta match exato no day_bucket
|
|
hit = amounts.find_by(day_bucket: day_bucket)
|
|
return [hit.amount.to_f, day_bucket] if hit
|
|
|
|
# 2) Tenta preço flat (day_bucket NULL)
|
|
hit = amounts.find_by(day_bucket: nil)
|
|
return [hit.amount.to_f, nil] if hit
|
|
|
|
# 3) Bucket pedido não tem amount + não é flat
|
|
avail = amounts.pluck(:day_bucket).map(&:to_s).reject(&:blank?).uniq.join(', ').presence || 'flat'
|
|
["'#{category.key}/#{period_key}' não tem preço pro dia escolhido (#{day_bucket}). Disponível: #{avail}.", nil]
|
|
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 normalize_period(raw)
|
|
key = raw.to_s.downcase.strip.tr('-', '_')
|
|
return key if PERIOD_KEYS.include?(key)
|
|
|
|
case key
|
|
when 'pernoite', 'pernoite_normal', 'promocional' then 'pernoite_promo'
|
|
when 'feriado', 'pernoite_feriado', 'sex_sab', 'final_de_semana' then 'pernoite_integral'
|
|
when '3', '3 h', 'tres_horas', 'permanencia', 'permanencia_3h' then '3h'
|
|
when 'diária' then 'diaria'
|
|
end
|
|
end
|
|
end
|
|
end
|