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
|
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
|
||||||
|
|||||||
@ -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