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>
This commit is contained in:
Rodribm10 2026-05-02 09:04:50 -03:00
parent a2bb613e68
commit 7995bc6fe6
7 changed files with 328 additions and 176 deletions

View File

@ -0,0 +1,29 @@
class CreateCaptainPricingTables < ActiveRecord::Migration[7.1]
# rubocop:disable Metrics/MethodLength
def change
add_column :captain_units, :extra_person_fee, :decimal, precision: 10, scale: 2, default: 0.0, null: false
add_column :captain_units, :currency, :string, default: 'BRL', null: false
create_table :captain_pricing_categories do |t|
t.references :captain_unit, null: false, foreign_key: { to_table: :captain_units }
t.string :key, null: false
t.jsonb :aliases, null: false, default: []
t.integer :extra_person_starts_at, null: false, default: 3
t.timestamps
end
add_index :captain_pricing_categories, [:captain_unit_id, :key], unique: true
create_table :captain_pricing_amounts do |t|
t.references :captain_pricing_category, null: false, foreign_key: { to_table: :captain_pricing_categories }
t.string :period, null: false
t.string :day_bucket
t.decimal :amount, precision: 10, scale: 2, null: false
t.timestamps
end
add_index :captain_pricing_amounts,
[:captain_pricing_category_id, :period, :day_bucket],
unique: true,
name: 'idx_captain_pricing_amount_uniq'
end
# rubocop:enable Metrics/MethodLength
end

View File

@ -0,0 +1,14 @@
class AddProvisioningColumnsToCaptainAssistants < ActiveRecord::Migration[7.1]
def change
add_column :captain_assistants, :hermes_subscription_secret, :string
add_column :captain_assistants, :hermes_port, :integer
add_column :captain_assistants, :parent_assistant_id, :bigint
add_index :captain_assistants, :parent_assistant_id
add_index :captain_assistants,
:hermes_port,
unique: true,
where: 'hermes_port IS NOT NULL',
name: 'idx_captain_assistants_hermes_port_unique'
end
end

150
db/seed_pricing_tables.rb Normal file
View File

@ -0,0 +1,150 @@
# Backfill one-time das tabelas de preço pra Dolce Amore (unit 4) e
# Express (unit 5) — copia o que estava hardcoded no PricingTables.rb
# antes da migração pra DB.
#
# Idempotente: roda find_or_create_by em tudo. Pode rodar várias vezes sem
# criar duplicata.
#
# Uso:
# docker exec iachat_iachat_app bundle exec rails runner db/seed_pricing_tables.rb
#
# rubocop:disable all
# Garante extra_person_fee + currency configurados nas units
dolce = Captain::Unit.find(4)
dolce.update!(extra_person_fee: 45.0, currency: 'BRL') if dolce.extra_person_fee.to_f.zero?
express = Captain::Unit.find(5)
express.update!(extra_person_fee: 0.0, currency: 'BRL')
DOLCE_AMORE_DATA = {
'apartamento' => {
aliases: ['apto', 'standard', 'apartamento standard', 'apartamento_standard'],
extra_person_starts_at: 3,
prices: { '3h' => 85.0, 'pernoite_promo' => 110.0, 'pernoite_integral' => 155.0, 'diaria' => 290.0 }
},
'suite_master' => {
aliases: ['master', 'suite master', 'suíte master', '2 andares'],
extra_person_starts_at: 3,
prices: { '3h' => 90.0, 'pernoite_promo' => 130.0, 'pernoite_integral' => 180.0, 'diaria' => 340.0 }
},
'suite_luxo' => {
aliases: ['luxo', 'suite luxo', 'suíte luxo', 'classica', 'clássica'],
extra_person_starts_at: 3,
prices: { '3h' => 90.0, 'pernoite_promo' => 130.0, 'pernoite_integral' => 180.0, 'diaria' => 340.0 }
},
'suite_tematica' => {
aliases: ['tematica', 'temática', 'suite tematica', 'suíte temática'],
extra_person_starts_at: 3,
prices: { '3h' => 90.0, 'pernoite_promo' => 130.0, 'pernoite_integral' => 180.0, 'diaria' => 340.0 }
},
'mini_chale_45' => {
aliases: ['mini chale', 'mini chalé', 'chale 45', 'chalé 45', 'mini chalé 45', 'mini_chale'],
extra_person_starts_at: 3,
prices: { '3h' => 100.0, 'pernoite_promo' => 140.0, 'pernoite_integral' => 190.0, 'diaria' => 400.0 }
},
'chale_2_suites' => {
aliases: ['chale 2', 'chalé 2', 'chale 2 suites', 'chalé 2 suítes', 'chale_2', '2 suites'],
extra_person_starts_at: 4,
prices: { '3h' => 165.0, 'pernoite_promo' => 240.0, 'pernoite_integral' => 350.0, 'diaria' => 490.0 }
},
'suite_ouro' => {
aliases: ['ouro', 'suite ouro', 'suíte ouro'],
extra_person_starts_at: 4,
prices: { '3h' => 230.0, 'pernoite_promo' => 340.0, 'pernoite_integral' => 440.0, 'diaria' => 830.0 }
},
'chale_master_4_suites' => {
aliases: ['chale master', 'chalé master', 'master 4 suites', 'chalé master 4 suítes', 'chale_master', '4 suites'],
extra_person_starts_at: 8,
prices: { '3h' => 360.0, 'pernoite_promo' => 510.0, 'pernoite_integral' => 580.0, 'diaria' => 1240.0 }
}
}.freeze
EXPRESS_DATA = {
'standard' => {
aliases: %w[standard comum básica basica apartamento\ standard],
extra_person_starts_at: 3,
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
}
},
'master' => {
aliases: ['master', 'melhor', 'suite master', 'suíte master'],
extra_person_starts_at: 3,
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
}
},
'singles' => {
aliases: %w[singles single sozinho],
extra_person_starts_at: 99,
prices: {
'pernoite_promo' => { 'mon_wed' => 80.0, 'thu_sun' => 110.0 },
'diaria' => 130.0
}
},
'familia' => {
aliases: %w[familia família familiar],
extra_person_starts_at: 99,
prices: {
'pernoite_promo' => 160.0,
'diaria' => 190.0
}
},
'singles_duplo' => {
aliases: ['singles duplo', 'singles_duplo', 'casal', 'duplo'],
extra_person_starts_at: 99,
prices: {
'pernoite_promo' => { 'mon_wed' => 180.0, 'thu_sun' => 220.0 },
'diaria' => 250.0
}
}
}.freeze
def upsert(unit, data)
data.each do |key, attrs|
cat = Captain::PricingCategory.find_or_initialize_by(captain_unit_id: unit.id, key: key)
cat.aliases = attrs[:aliases]
cat.extra_person_starts_at = attrs[:extra_person_starts_at]
cat.save!
attrs[:prices].each do |period, value|
if value.is_a?(Hash)
value.each do |bucket, amount|
row = Captain::PricingAmount.find_or_initialize_by(
captain_pricing_category_id: cat.id, period: period, day_bucket: bucket
)
row.amount = amount
row.save!
end
else
row = Captain::PricingAmount.find_or_initialize_by(
captain_pricing_category_id: cat.id, period: period, day_bucket: nil
)
row.amount = value
row.save!
end
end
puts "✓ unit=#{unit.id} #{key} (#{attrs[:prices].size} periods)"
end
end
upsert(dolce, DOLCE_AMORE_DATA)
upsert(express, EXPRESS_DATA)
puts "--- summary ---"
puts "dolce categories: #{dolce.pricing_categories.count}"
puts "dolce amounts: #{Captain::PricingAmount.joins(:pricing_category).where(captain_pricing_categories: { captain_unit_id: dolce.id }).count}"
puts "express categories: #{express.pricing_categories.count}"
puts "express amounts: #{Captain::PricingAmount.joins(:pricing_category).where(captain_pricing_categories: { captain_unit_id: express.id }).count}"
# rubocop:enable all

View File

@ -0,0 +1,40 @@
# == Schema Information
#
# Table name: captain_pricing_amounts
#
# id :bigint not null, primary key
# amount :decimal(10, 2) not null
# day_bucket :string
# period :string not null
# created_at :datetime not null
# updated_at :datetime not null
# captain_pricing_category_id :bigint not null
#
# Indexes
#
# idx_captain_pricing_amount_uniq (captain_pricing_category_id,period,day_bucket) UNIQUE
# index_captain_pricing_amounts_on_captain_pricing_category_id (captain_pricing_category_id)
#
# Foreign Keys
#
# fk_rails_... (captain_pricing_category_id => captain_pricing_categories.id)
#
# Valor por (categoria, período, dia da semana). day_bucket NULL = preço
# único todos os dias. day_bucket='mon_wed' = seg-qua. 'thu_sun' = qui-dom.
class Captain::PricingAmount < ApplicationRecord
self.table_name = 'captain_pricing_amounts'
PERIODS = %w[2h 3h 4h 5h pernoite_promo pernoite_integral diaria].freeze
DAY_BUCKETS = %w[mon_wed thu_sun].freeze
belongs_to :pricing_category,
class_name: 'Captain::PricingCategory',
foreign_key: :captain_pricing_category_id,
inverse_of: :amounts
validates :period, inclusion: { in: PERIODS }
validates :day_bucket, inclusion: { in: DAY_BUCKETS, allow_nil: true }
validates :amount, numericality: { greater_than: 0 }
validates :captain_pricing_category_id,
uniqueness: { scope: %i[period day_bucket] }
end

View File

@ -0,0 +1,55 @@
# == Schema Information
#
# Table name: captain_pricing_categories
#
# id :bigint not null, primary key
# aliases :jsonb not null
# extra_person_starts_at :integer default(3), not null
# key :string not null
# created_at :datetime not null
# updated_at :datetime not null
# captain_unit_id :bigint not null
#
# Indexes
#
# index_captain_pricing_categories_on_captain_unit_id (captain_unit_id)
# index_captain_pricing_categories_on_captain_unit_id_and_key (captain_unit_id,key) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (captain_unit_id => captain_units.id)
#
# Categoria de preço por unidade — espelha o que tava em PricingTables.rb
# como Ruby hash. `key` é canônico ('apartamento', 'standard', etc),
# `aliases` é array de strings que o LLM pode digitar e bate via fuzzy
# match. `extra_person_starts_at` controla a partir de qual hóspede a taxa
# extra começa (default 3 = casal incluso).
class Captain::PricingCategory < ApplicationRecord
self.table_name = 'captain_pricing_categories'
belongs_to :captain_unit, class_name: 'Captain::Unit', inverse_of: :pricing_categories
has_many :amounts,
class_name: 'Captain::PricingAmount',
foreign_key: :captain_pricing_category_id,
inverse_of: :pricing_category,
dependent: :destroy
validates :key, presence: true, uniqueness: { scope: :captain_unit_id }
validates :extra_person_starts_at, numericality: { greater_than_or_equal_to: 1 }
validate :aliases_is_array
def matches?(needle)
return false if needle.blank?
candidates = ([key.to_s.tr('_', ' ')] + aliases.to_a).map { |s| s.to_s.downcase.strip }
candidates.include?(needle.to_s.downcase.strip.tr('_', ' ').squeeze(' '))
end
private
def aliases_is_array
return if aliases.is_a?(Array)
errors.add(:aliases, 'must be an array')
end
end

View File

@ -63,6 +63,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 :pricing_categories, class_name: 'Captain::PricingCategory', foreign_key: :captain_unit_id,
inverse_of: :captain_unit, dependent: :destroy
encrypts :inter_client_secret
encrypts :inter_account_number

View File

@ -1,209 +1,86 @@
# Tabelas de preço por unidade do Captain — fonte de verdade backend pra
# tools MCP que precisam validar valor. Espelha o que está nos prompts/skills
# das assistentes (Valentina, Jasmines, etc), mas centralizada e auditável.
# tools MCP que precisam validar valor.
#
# Quando o LLM chama `generate_pix`, ele NÃO informa o valor; apenas
# categoria/período. Tool calcula via essa tabela. Isso impede que o LLM
# invente um valor (ex: "aplicou desconto VIP" sozinho).
# categoria/período/data. Tool calcula via essa tabela no banco. Isso
# impede que o LLM invente um valor.
#
# Estrutura: TABLES[captain_unit_id] = {
# categories: {
# '<categoria_key>' => {
# # Preço por período. Aceita 2 formatos:
# # 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_rules: { '<categoria_key>' => starts_at_guest_n }
# }
# rubocop:disable Metrics/ModuleLength
# 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
# 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 = {
# Motel Dolce Amore — Ponta Negra, Natal/RN (captain_unit_id=4)
4 => {
currency: 'BRL',
extra_person_fee: 45.0,
# Por categoria, a partir de qual hóspede a taxa começa a contar.
# Ex: "starts_at_guest_n=3" significa que 3ª pessoa em diante paga.
# Default 3 — base do quarto inclui 2 pessoas (casal).
categories: {
'apartamento' => {
prices: { '3h' => 85.0, 'pernoite_promo' => 110.0, 'pernoite_integral' => 155.0, 'diaria' => 290.0 },
extra_person_starts_at: 3,
aliases: ['apto', 'standard', 'apartamento standard', 'apartamento_standard']
},
'suite_master' => {
prices: { '3h' => 90.0, 'pernoite_promo' => 130.0, 'pernoite_integral' => 180.0, 'diaria' => 340.0 },
extra_person_starts_at: 3,
aliases: ['master', 'suite master', 'suíte master', '2 andares']
},
'suite_luxo' => {
prices: { '3h' => 90.0, 'pernoite_promo' => 130.0, 'pernoite_integral' => 180.0, 'diaria' => 340.0 },
extra_person_starts_at: 3,
aliases: ['luxo', 'suite luxo', 'suíte luxo', 'classica', 'clássica']
},
'suite_tematica' => {
prices: { '3h' => 90.0, 'pernoite_promo' => 130.0, 'pernoite_integral' => 180.0, 'diaria' => 340.0 },
extra_person_starts_at: 3,
aliases: ['tematica', 'temática', 'suite tematica', 'suíte temática']
},
'mini_chale_45' => {
prices: { '3h' => 100.0, 'pernoite_promo' => 140.0, 'pernoite_integral' => 190.0, 'diaria' => 400.0 },
extra_person_starts_at: 3,
aliases: ['mini chale', 'mini chalé', 'chale 45', 'chalé 45', 'mini chalé 45', 'mini_chale']
},
'chale_2_suites' => {
prices: { '3h' => 165.0, 'pernoite_promo' => 240.0, 'pernoite_integral' => 350.0, 'diaria' => 490.0 },
extra_person_starts_at: 4,
aliases: ['chale 2', 'chalé 2', 'chale 2 suites', 'chalé 2 suítes', 'chale_2', '2 suites']
},
'suite_ouro' => {
prices: { '3h' => 230.0, 'pernoite_promo' => 340.0, 'pernoite_integral' => 440.0, 'diaria' => 830.0 },
extra_person_starts_at: 4,
aliases: ['ouro', 'suite ouro', 'suíte ouro']
},
'chale_master_4_suites' => {
prices: { '3h' => 360.0, 'pernoite_promo' => 510.0, 'pernoite_integral' => 580.0, 'diaria' => 1240.0 },
extra_person_starts_at: 8,
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
class << self
# Retorna {amount:, breakdown:} ou erro {error:} pra uma cobrança.
# period: '3h' | 'pernoite_promo' | 'pernoite_integral' | 'diaria'
# check_in_at: Time/String ISO8601. Determina o bucket de dia da semana
# quando o preço varia (mon_wed/thu_sun). Default: agora.
# total_guests: número TOTAL de hóspedes — a função calcula extras
# 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]
return { error: "Unidade #{unit_id} não tem tabela de preços cadastrada." } if table.blank?
unit = Captain::Unit.find_by(id: unit_id)
return { error: "Unidade #{unit_id} não cadastrada." } if unit.blank?
cat_key, cat_data = find_category(table, suite_category)
return { error: "Categoria '#{suite_category}' não reconhecida nesta unidade." } if cat_data.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?
raw = cat_data[:prices][period_key]
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)
base, used_bucket = resolve_amount(category, period_key, day_bucket)
return { error: base } if base.is_a?(String)
starts_at = cat_data[:extra_person_starts_at] || 3
starts_at = category.extra_person_starts_at || 3
extra_guests = [total_guests.to_i - (starts_at - 1), 0].max
extra_total = extra_guests * table[:extra_person_fee]
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: cat_key,
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: table[:extra_person_fee],
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)
TABLES.dig(unit_id, :categories)&.keys || []
Captain::PricingCategory.where(captain_unit_id: unit_id).order(:key).pluck(:key)
end
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)
def find_category(unit, raw)
return nil if raw.blank?
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]
needle = raw.to_s.downcase.strip.tr('_', ' ').squeeze(' ')
unit.pricing_categories.find { |cat| cat.matches?(needle) }
end
[price.to_f, day_bucket]
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)
@ -219,23 +96,10 @@ module Captain::Mcp::PricingTables
[1, 2, 3].include?(time.wday) ? 'mon_wed' : 'thu_sun'
end
def find_category(table, raw)
needle = raw.to_s.downcase.strip.tr('_', ' ').squeeze(' ')
return [nil, nil] if needle.blank?
table[:categories].each do |key, data|
candidates = ([key.tr('_', ' ')] + data[:aliases].to_a).map { |c| c.to_s.downcase.strip }
return [key, data] if candidates.any?(needle)
end
[nil, nil]
end
def normalize_period(raw)
key = raw.to_s.downcase.strip.tr('-', '_')
return key if PERIOD_KEYS.include?(key)
# aceita variações comuns
case key
when 'pernoite', 'pernoite_normal', 'promocional' then 'pernoite_promo'
when 'feriado', 'pernoite_feriado', 'sex_sab', 'final_de_semana' then 'pernoite_integral'
@ -243,7 +107,5 @@ module Captain::Mcp::PricingTables
when 'diária' then 'diaria'
end
end
# rubocop:enable Metrics/MethodLength,Metrics/AbcSize
end
end
# rubocop:enable Metrics/ModuleLength