feat(captain): provisionamento automático de Captain::Unit em reserva_hotel.unidades

Hook after_commit on:create no Captain::Unit dispara
ProvisionUnitInSupabaseJob, que upserta a unit em reserva_hotel.unidades
via Supabase REST (UNIQUE on tenant_id+chatwoot_unit_id) e grava IDs no
Captain::Unit (supabase_unit_id, supabase_tenant_id, supabase_marca_id).

Sem isso, criar nova unidade no painel Pix não habilitava roleta — a row
no Supabase ficava ausente e OfferService caía em "tenant não resolvido".

Inclui rake captain:reprovision_unit_in_supabase[id] + provision_all
pra reconciliação manual e migration retroativa.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-05-01 00:21:20 -03:00
parent e7f3723938
commit c5cd15665e
6 changed files with 326 additions and 0 deletions

View File

@ -0,0 +1,9 @@
class AddSupabaseMappingToCaptainUnits < ActiveRecord::Migration[7.1]
def change
add_column :captain_units, :supabase_unit_id, :uuid
add_column :captain_units, :supabase_tenant_id, :bigint, default: 1
add_column :captain_units, :supabase_marca_id, :uuid
add_index :captain_units, :supabase_unit_id, unique: true, where: 'supabase_unit_id IS NOT NULL'
end
end

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
# Wrapper assíncrono pro ProvisionUnitInSupabaseService.
# Disparado via Captain::Unit#after_commit (criação) e pelo rake de reconciliação.
# Falhas não levantam exception — só logam — pra não bloquear criação da unit.
class Captain::Reserva::ProvisionUnitInSupabaseJob < ApplicationJob
queue_as :low
def perform(unit_id)
unit = Captain::Unit.find_by(id: unit_id)
return Rails.logger.warn("[ProvisionUnitInSupabaseJob] unit=#{unit_id} não encontrada") if unit.blank?
result = Captain::Reserva::ProvisionUnitInSupabaseService.new(unit: unit).perform
return if result[:success]
Rails.logger.warn(
"[ProvisionUnitInSupabaseJob] unit=#{unit_id} falhou: #{result[:error]}"
)
end
end

View File

@ -70,6 +70,8 @@ class Captain::Unit < ApplicationRecord
validates :name, presence: true validates :name, presence: true
validate :proactive_pix_polling_requires_inter_credentials validate :proactive_pix_polling_requires_inter_credentials
after_commit :enqueue_supabase_provisioning, on: :create
def concierge_persona_name def concierge_persona_name
concierge_config_hash['persona_name'].presence || 'Sofia' concierge_config_hash['persona_name'].presence || 'Sofia'
end end
@ -133,4 +135,10 @@ class Captain::Unit < ApplicationRecord
path # Retorna original se nenhum caminho for encontrado path # Retorna original se nenhum caminho for encontrado
end end
def enqueue_supabase_provisioning
Captain::Reserva::ProvisionUnitInSupabaseJob.perform_later(id)
rescue StandardError => e
Rails.logger.warn("[Captain::Unit##{id}] enqueue ProvisionUnitInSupabaseJob falhou: #{e.class} - #{e.message}")
end
end end

View File

@ -0,0 +1,151 @@
# frozen_string_literal: true
# Faz upsert da Captain::Unit em reserva_hotel.unidades (Supabase reserva-1001).
# Disparado automaticamente após criação da unit (via after_commit) e por rake
# de reconciliação (captain:reprovision_unit_in_supabase).
#
# Pré-requisitos:
# - ENV RESERVA_1001_SUPABASE_URL / _ANON_KEY / _SCHEMA setados
# - Captain::Unit.brand cadastrada com nome igual ao da marcas.nome no Supabase
# - UNIQUE (tenant_id, chatwoot_unit_id) em reserva_hotel.unidades
#
# Idempotente: chamadas repetidas atualizam a mesma row.
class Captain::Reserva::ProvisionUnitInSupabaseService
DEFAULT_SCHEMA = 'reserva_hotel'
DEFAULT_TENANT_ID = 1
# Conta padrão usada quando a unidade ainda não tem id_conta_pagamento próprio.
# Cada marca pode editar isso direto no Supabase posteriormente.
DEFAULT_CONTA_PAGAMENTO_ID = 'e227a8b3-480a-46a8-987f-2cca6395760b'
def initialize(unit:)
@unit = unit
end
def perform # rubocop:disable Metrics/AbcSize
precheck = preflight_error
return precheck if precheck
marca_id = resolve_marca_id
return error("marca '#{unit.brand.name}' não encontrada em reserva_hotel.marcas") if marca_id.blank?
row = upsert_unidade(build_payload(marca_id))
return error('falha ao gravar unidade no Supabase') if row.blank?
persist_supabase_ids(row)
Rails.logger.info("[Captain::Reserva::ProvisionUnit] unit=#{unit.id} (#{unit.name}) -> supabase_unit=#{row['id']}")
{ success: true, supabase_unit_id: row['id'] }
rescue StandardError => e
Rails.logger.error("[Captain::Reserva::ProvisionUnit] unit=#{unit&.id} #{e.class}: #{e.message}")
error(e.message)
end
def preflight_error
return error('unit ausente') if unit.blank?
return error('unit sem brand vinculada') if unit.brand.blank?
return error('Supabase não configurado (env vars ausentes)') unless supabase_configured?
nil
end
private
attr_reader :unit
def error(msg)
{ success: false, error: msg }
end
def supabase_configured?
supabase_url.present? && supabase_key.present?
end
def resolve_marca_id
rows = supabase_get('marcas', { nome: "eq.#{unit.brand.name}", tenant_id: "eq.#{tenant_id}", select: 'id' })
rows.first&.dig('id')
end
# update_columns intencional: pula validações/callbacks pra evitar
# disparar after_commit em loop (chamado pelo próprio after_commit).
def persist_supabase_ids(row)
unit.update_columns( # rubocop:disable Rails/SkipsModelValidations
supabase_unit_id: row['id'],
supabase_tenant_id: row['tenant_id'],
supabase_marca_id: row['id_marca']
)
end
def build_payload(marca_id)
{
nome: unit.name,
id_marca: marca_id,
id_conta_pagamento: unit.supabase_unit_id.present? ? nil : DEFAULT_CONTA_PAGAMENTO_ID,
tenant_id: tenant_id,
chatwoot_unit_id: unit.id,
categorias_visiveis: Array(unit.visible_suite_categories),
ativa: true
}.compact
end
def upsert_unidade(payload)
url = "#{supabase_url}/rest/v1/unidades?on_conflict=tenant_id,chatwoot_unit_id"
response = http.post(url) do |req|
apply_upsert_headers(req)
req.body = payload.to_json
end
return nil unless response.success?
Array(JSON.parse(response.body)).first
rescue JSON::ParserError
nil
end
def apply_upsert_headers(req)
req.headers['apikey'] = supabase_key
req.headers['Authorization'] = "Bearer #{supabase_key}"
req.headers['Content-Profile'] = supabase_schema
req.headers['Content-Type'] = 'application/json'
req.headers['Accept'] = 'application/json'
req.headers['Accept-Encoding'] = 'identity'
req.headers['Prefer'] = 'resolution=merge-duplicates,return=representation'
end
def supabase_get(table, query)
url = "#{supabase_url}/rest/v1/#{table}"
response = http.get(url, query) do |req|
req.headers['apikey'] = supabase_key
req.headers['Authorization'] = "Bearer #{supabase_key}"
req.headers['Accept-Profile'] = supabase_schema
req.headers['Accept'] = 'application/json'
req.headers['Accept-Encoding'] = 'identity'
end
return [] unless response.success?
JSON.parse(response.body)
rescue JSON::ParserError
[]
end
def http
@http ||= Faraday.new do |f|
f.adapter Faraday.default_adapter
f.options.timeout = 8
f.options.open_timeout = 4
end
end
def supabase_url
ENV.fetch('RESERVA_1001_SUPABASE_URL', nil)&.chomp('/')
end
def supabase_key
ENV.fetch('RESERVA_1001_SUPABASE_ANON_KEY', nil)
end
def supabase_schema
ENV.fetch('RESERVA_1001_SUPABASE_SCHEMA', DEFAULT_SCHEMA)
end
def tenant_id
unit.supabase_tenant_id.presence || DEFAULT_TENANT_ID
end
end

View File

@ -0,0 +1,38 @@
namespace :captain do
desc 'Provisiona/reconcilia 1 Captain::Unit em reserva_hotel.unidades (Supabase reserva-1001)'
task :reprovision_unit_in_supabase, [:unit_id] => :environment do |_t, args|
unit_id = args[:unit_id]
abort 'uso: rake captain:reprovision_unit_in_supabase[<unit_id>]' if unit_id.blank?
unit = Captain::Unit.find_by(id: unit_id)
abort "Captain::Unit #{unit_id} não encontrada" if unit.blank?
result = Captain::Reserva::ProvisionUnitInSupabaseService.new(unit: unit).perform
if result[:success]
puts "[OK] unit=#{unit.id} (#{unit.name}) -> supabase_unit=#{result[:supabase_unit_id]}"
else
puts "[ERRO] unit=#{unit.id} (#{unit.name}): #{result[:error]}"
exit 1
end
end
desc 'Reconcilia TODAS as Captain::Unit em reserva_hotel.unidades (idempotente)'
task provision_all_units_in_supabase: :environment do
units = Captain::Unit.includes(:brand).order(:id)
puts "Reconciliando #{units.count} unidade(s)..."
failures = 0
units.each do |unit|
result = Captain::Reserva::ProvisionUnitInSupabaseService.new(unit: unit).perform
if result[:success]
puts " [OK] unit=#{unit.id} (#{unit.name}) -> #{result[:supabase_unit_id]}"
else
failures += 1
puts " [ERRO] unit=#{unit.id} (#{unit.name}): #{result[:error]}"
end
end
puts "Done. #{units.count - failures}/#{units.count} OK."
exit 1 if failures.positive?
end
end

View File

@ -0,0 +1,100 @@
require 'rails_helper'
RSpec.describe Captain::Reserva::ProvisionUnitInSupabaseService do
let(:account) { create(:account) }
let(:brand) { Captain::Brand.create!(account: account, name: 'Hotel 1001 Noites Prime') }
let(:unit) do
Captain::Unit.create!(
account: account,
brand: brand,
name: 'PrimeAL',
visible_suite_categories: %w[Alexa Stilo Hidromassagem]
)
end
let(:supabase_url) { 'https://supabase.test' }
let(:supabase_key) { 'anon-key-test' }
let(:marca_uuid) { 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' }
let(:unidade_uuid) { 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' }
before do
allow(ENV).to receive(:fetch).and_call_original
allow(ENV).to receive(:fetch).with('RESERVA_1001_SUPABASE_URL', nil).and_return(supabase_url)
allow(ENV).to receive(:fetch).with('RESERVA_1001_SUPABASE_ANON_KEY', nil).and_return(supabase_key)
allow(ENV).to receive(:fetch).with('RESERVA_1001_SUPABASE_SCHEMA', described_class::DEFAULT_SCHEMA).and_return('reserva_hotel')
end
context 'when tudo está configurado' do
before do
stub_request(:get, "#{supabase_url}/rest/v1/marcas")
.with(query: hash_including('nome' => "eq.#{brand.name}", 'tenant_id' => 'eq.1'))
.to_return(status: 200, body: [{ id: marca_uuid }].to_json, headers: { 'Content-Type' => 'application/json' })
stub_request(:post, "#{supabase_url}/rest/v1/unidades")
.with(query: { 'on_conflict' => 'tenant_id,chatwoot_unit_id' })
.to_return(
status: 201,
body: [{ id: unidade_uuid, tenant_id: 1, id_marca: marca_uuid }].to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'retorna sucesso e grava IDs do Supabase no Captain::Unit' do
result = described_class.new(unit: unit).perform
expect(result[:success]).to be true
expect(result[:supabase_unit_id]).to eq(unidade_uuid)
unit.reload
expect(unit.supabase_unit_id).to eq(unidade_uuid)
expect(unit.supabase_tenant_id).to eq(1)
expect(unit.supabase_marca_id).to eq(marca_uuid)
end
it 'envia categorias_visiveis e chatwoot_unit_id no payload' do
described_class.new(unit: unit).perform
expect(WebMock).to have_requested(:post, "#{supabase_url}/rest/v1/unidades").with(
query: { 'on_conflict' => 'tenant_id,chatwoot_unit_id' },
body: hash_including(
'nome' => unit.name,
'id_marca' => marca_uuid,
'tenant_id' => 1,
'chatwoot_unit_id' => unit.id,
'categorias_visiveis' => %w[Alexa Stilo Hidromassagem],
'ativa' => true
)
)
end
end
context 'when há erros' do
it 'retorna erro quando ENV não está configurado' do
allow(ENV).to receive(:fetch).with('RESERVA_1001_SUPABASE_URL', nil).and_return(nil)
result = described_class.new(unit: unit).perform
expect(result[:success]).to be false
expect(result[:error]).to match(/Supabase não configurado/)
end
it 'retorna erro quando marca não existe no Supabase' do
stub_request(:get, "#{supabase_url}/rest/v1/marcas")
.with(query: hash_including('nome' => "eq.#{brand.name}"))
.to_return(status: 200, body: '[]', headers: { 'Content-Type' => 'application/json' })
result = described_class.new(unit: unit).perform
expect(result[:success]).to be false
expect(result[:error]).to match(/marca .* não encontrada/)
end
it 'retorna erro e não grava IDs quando upsert falha' do
stub_request(:get, "#{supabase_url}/rest/v1/marcas")
.to_return(status: 200, body: [{ id: marca_uuid }].to_json, headers: { 'Content-Type' => 'application/json' })
stub_request(:post, "#{supabase_url}/rest/v1/unidades")
.to_return(status: 500, body: '{"message":"err"}')
result = described_class.new(unit: unit).perform
expect(result[:success]).to be false
expect(unit.reload.supabase_unit_id).to be_nil
end
end
end