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:
parent
e7f3723938
commit
c5cd15665e
@ -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
|
||||
@ -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
|
||||
@ -70,6 +70,8 @@ class Captain::Unit < ApplicationRecord
|
||||
validates :name, presence: true
|
||||
validate :proactive_pix_polling_requires_inter_credentials
|
||||
|
||||
after_commit :enqueue_supabase_provisioning, on: :create
|
||||
|
||||
def concierge_persona_name
|
||||
concierge_config_hash['persona_name'].presence || 'Sofia'
|
||||
end
|
||||
@ -133,4 +135,10 @@ class Captain::Unit < ApplicationRecord
|
||||
|
||||
path # Retorna original se nenhum caminho for encontrado
|
||||
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
|
||||
|
||||
@ -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
|
||||
38
lib/tasks/captain_reserva.rake
Normal file
38
lib/tasks/captain_reserva.rake
Normal 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
|
||||
@ -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
|
||||
Loading…
Reference in New Issue
Block a user