From c5cd15665e433f4cd9cede92af1125d200eac1e4 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Fri, 1 May 2026 00:21:20 -0300 Subject: [PATCH] =?UTF-8?q?feat(captain):=20provisionamento=20autom=C3=A1t?= =?UTF-8?q?ico=20de=20Captain::Unit=20em=20reserva=5Fhotel.unidades?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...0_add_supabase_mapping_to_captain_units.rb | 9 ++ .../reserva/provision_unit_in_supabase_job.rb | 20 +++ enterprise/app/models/captain/unit.rb | 8 + .../provision_unit_in_supabase_service.rb | 151 ++++++++++++++++++ lib/tasks/captain_reserva.rake | 38 +++++ ...provision_unit_in_supabase_service_spec.rb | 100 ++++++++++++ 6 files changed, 326 insertions(+) create mode 100644 db/migrate/20260501030000_add_supabase_mapping_to_captain_units.rb create mode 100644 enterprise/app/jobs/captain/reserva/provision_unit_in_supabase_job.rb create mode 100644 enterprise/app/services/captain/reserva/provision_unit_in_supabase_service.rb create mode 100644 lib/tasks/captain_reserva.rake create mode 100644 spec/enterprise/services/captain/reserva/provision_unit_in_supabase_service_spec.rb diff --git a/db/migrate/20260501030000_add_supabase_mapping_to_captain_units.rb b/db/migrate/20260501030000_add_supabase_mapping_to_captain_units.rb new file mode 100644 index 000000000..b89ec8deb --- /dev/null +++ b/db/migrate/20260501030000_add_supabase_mapping_to_captain_units.rb @@ -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 diff --git a/enterprise/app/jobs/captain/reserva/provision_unit_in_supabase_job.rb b/enterprise/app/jobs/captain/reserva/provision_unit_in_supabase_job.rb new file mode 100644 index 000000000..55527b95c --- /dev/null +++ b/enterprise/app/jobs/captain/reserva/provision_unit_in_supabase_job.rb @@ -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 diff --git a/enterprise/app/models/captain/unit.rb b/enterprise/app/models/captain/unit.rb index ee545c9d3..47a62ad11 100644 --- a/enterprise/app/models/captain/unit.rb +++ b/enterprise/app/models/captain/unit.rb @@ -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 diff --git a/enterprise/app/services/captain/reserva/provision_unit_in_supabase_service.rb b/enterprise/app/services/captain/reserva/provision_unit_in_supabase_service.rb new file mode 100644 index 000000000..5fee29703 --- /dev/null +++ b/enterprise/app/services/captain/reserva/provision_unit_in_supabase_service.rb @@ -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 diff --git a/lib/tasks/captain_reserva.rake b/lib/tasks/captain_reserva.rake new file mode 100644 index 000000000..62ac954d3 --- /dev/null +++ b/lib/tasks/captain_reserva.rake @@ -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[]' 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 diff --git a/spec/enterprise/services/captain/reserva/provision_unit_in_supabase_service_spec.rb b/spec/enterprise/services/captain/reserva/provision_unit_in_supabase_service_spec.rb new file mode 100644 index 000000000..20860297a --- /dev/null +++ b/spec/enterprise/services/captain/reserva/provision_unit_in_supabase_service_spec.rb @@ -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