iachat/enterprise/app/services/captain/mcp/pricing_tables.rb
Rodribm10 7995bc6fe6 feat(captain): pricing em DB + colunas de provisionamento Hermes
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>
2026-05-02 09:04:50 -03:00

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