Plano de Ação Executável

Base Principal no Supabase

Plano de ação pronto para produção (READY) para implementar a base de dados consolidada do DBCHECKOUT

Versão 1.0Ready to BuildJaneiro 2025

Objetivo do Supabase no DBCHECKOUT

O Supabase será o BANCO DE REDE (CORE), responsável por toda a camada analítica e governança da plataforma.

Responsabilidades

  • • Multi-tenancy (rede → loja)
  • • Governança e segurança
  • • Consolidação analítica
  • • Base única do dashboard
  • • Fonte oficial para IA
  • • Segurança (RLS + Auth)

Não é Responsável Por

  • • ❌ Banco operacional
  • • ❌ Substituir MySQL legado
  • • ❌ Transações de venda
  • • ❌ Dados em tempo real
  • • ❌ Operação das lojas

0Preparação (Antes de Criar Tabelas)

0.1 Criar Projeto Supabase

Nome do projeto: dbcheckout-core

Região: Mais próxima do backend Node (ex: South America - São Paulo)

Plano: Free (inicial) → Pro (produção)

0.2 Definir Padrões Globais

Timezone: America/Sao_Paulo

Encoding: UTF-8

Idioma técnico: PT-BR

0.3 Definir Papéis (Roles)

service_role → Backend Node

Usado apenas pelo backend para inserir KPIs, reprocessar períodos e jobs de consolidação

authenticated → Usuários Logados

Usado pelo frontend para leitura de dados, sempre filtrado por RLS

anon → Nunca Acessa Dados

Desabilitado para segurança, nenhum acesso anônimo permitido

⚠️ CRÍTICO: Frontend nunca usa service_role

1Modelo Multi-Tenant (OBRIGATÓRIO)

1.1 Tabela redes (TENANT)

01_redes.sql
CREATE TABLE redes (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  nome TEXT NOT NULL,
  cnpj TEXT,
  ativo BOOLEAN DEFAULT TRUE,
  created_at TIMESTAMP DEFAULT now()
);

-- Índice para busca por CNPJ
CREATE INDEX idx_redes_cnpj ON redes(cnpj);

-- Comentários
COMMENT ON TABLE redes IS 'Entidade raiz do multi-tenant';
COMMENT ON COLUMN redes.id IS 'Identificador único da rede';
COMMENT ON COLUMN redes.cnpj IS 'CNPJ da rede (opcional)';

1.2 Tabela lojas

02_lojas.sql
CREATE TABLE lojas (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  rede_id UUID NOT NULL REFERENCES redes(id) ON DELETE CASCADE,
  nome TEXT NOT NULL,
  codigo TEXT,
  endereco TEXT,
  cidade TEXT,
  estado TEXT,
  ativo BOOLEAN DEFAULT TRUE,
  created_at TIMESTAMP DEFAULT now()
);

-- Índices
CREATE INDEX idx_lojas_rede ON lojas(rede_id);
CREATE INDEX idx_lojas_codigo ON lojas(codigo);

-- Constraint de código único por rede
CREATE UNIQUE INDEX idx_lojas_codigo_rede 
ON lojas(rede_id, codigo) WHERE codigo IS NOT NULL;

-- Comentários
COMMENT ON TABLE lojas IS 'Lojas pertencentes a uma rede';
COMMENT ON COLUMN lojas.rede_id IS 'Referência à rede proprietária';
COMMENT ON COLUMN lojas.codigo IS 'Código interno da loja (opcional)';

1.3 Tabela profiles (Usuários)

03_profiles.sql
CREATE TABLE profiles (
  id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
  rede_id UUID NOT NULL REFERENCES redes(id) ON DELETE CASCADE,
  loja_id UUID REFERENCES lojas(id) ON DELETE SET NULL,
  perfil TEXT NOT NULL CHECK (
    perfil IN ('super_admin', 'admin_rede', 'gerente_loja', 'operador')
  ),
  nome TEXT,
  email TEXT,
  ativo BOOLEAN DEFAULT TRUE,
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now()
);

-- Índices
CREATE INDEX idx_profiles_rede ON profiles(rede_id);
CREATE INDEX idx_profiles_loja ON profiles(loja_id);
CREATE INDEX idx_profiles_perfil ON profiles(perfil);

-- Comentários
COMMENT ON TABLE profiles IS 'Perfis de usuários com autorização';
COMMENT ON COLUMN profiles.id IS 'Referência ao auth.users';
COMMENT ON COLUMN profiles.rede_id IS 'Rede à qual o usuário pertence';
COMMENT ON COLUMN profiles.loja_id IS 'Loja específica (NULL para admin_rede)';
COMMENT ON COLUMN profiles.perfil IS 'Nível de acesso do usuário';

📌 Separação de Responsabilidades:

  • auth.users = Identidade (login, senha, email)
  • profiles = Autorização (rede, loja, perfil)

2Dimensão Tempo (CRÍTICA)

2.1 Criar dim_tempo

04_dim_tempo.sql
CREATE TABLE dim_tempo (
  data DATE PRIMARY KEY,
  ano INT NOT NULL,
  mes INT NOT NULL,
  mes_nome TEXT NOT NULL,
  semana_ano INT NOT NULL,
  semana_mes INT NOT NULL,
  dia_mes INT NOT NULL,
  dia_semana INT NOT NULL,
  dia_nome TEXT NOT NULL,
  is_fim_semana BOOLEAN NOT NULL,
  is_feriado BOOLEAN DEFAULT FALSE,
  nome_feriado TEXT
);

-- Índices para performance
CREATE INDEX idx_dim_tempo_ano ON dim_tempo(ano);
CREATE INDEX idx_dim_tempo_mes ON dim_tempo(ano, mes);
CREATE INDEX idx_dim_tempo_semana ON dim_tempo(ano, semana_ano);

-- Comentários
COMMENT ON TABLE dim_tempo IS 'Dimensão temporal imutável';
COMMENT ON COLUMN dim_tempo.semana_ano IS 'Semana ISO 8601 (segunda = início)';
COMMENT ON COLUMN dim_tempo.is_fim_semana IS 'Sábado ou domingo';

2.2 Popular Dimensão Tempo

05_popular_dim_tempo.sql
-- Popular dimensão tempo: 5 anos passados + 5 anos futuros
INSERT INTO dim_tempo (
  data, ano, mes, mes_nome, semana_ano, semana_mes,
  dia_mes, dia_semana, dia_nome, is_fim_semana
)
SELECT
  d::date AS data,
  EXTRACT(YEAR FROM d) AS ano,
  EXTRACT(MONTH FROM d) AS mes,
  TO_CHAR(d, 'Month') AS mes_nome,
  EXTRACT(WEEK FROM d) AS semana_ano,
  CEIL(EXTRACT(DAY FROM d) / 7.0) AS semana_mes,
  EXTRACT(DAY FROM d) AS dia_mes,
  EXTRACT(DOW FROM d) AS dia_semana,
  TO_CHAR(d, 'Day') AS dia_nome,
  EXTRACT(DOW FROM d) IN (0, 6) AS is_fim_semana
FROM generate_series(
  CURRENT_DATE - INTERVAL '5 years',
  CURRENT_DATE + INTERVAL '5 years',
  '1 day'::interval
) AS d;

-- Verificar população
SELECT 
  MIN(data) AS data_inicial,
  MAX(data) AS data_final,
  COUNT(*) AS total_dias
FROM dim_tempo;

📌 IMPORTANTE: Nunca calcular data no frontend. Sempre usar dim_tempo como referência.

3Tabelas Consolidadas (CORE ANALÍTICO)

3.1 KPI Diário (Fonte Única)

06_kpi_vendas_dia.sql
CREATE TABLE kpi_vendas_dia (
  rede_id UUID NOT NULL REFERENCES redes(id) ON DELETE CASCADE,
  loja_id UUID NOT NULL REFERENCES lojas(id) ON DELETE CASCADE,
  data DATE NOT NULL REFERENCES dim_tempo(data),
  ano INT NOT NULL,
  mes INT NOT NULL,
  semana_ano INT NOT NULL,
  
  -- KPIs Financeiros
  faturamento NUMERIC(14,2) NOT NULL DEFAULT 0,
  custo NUMERIC(14,2) NOT NULL DEFAULT 0,
  margem NUMERIC(14,2) NOT NULL DEFAULT 0,
  margem_percent NUMERIC(5,2),
  
  -- KPIs Operacionais
  pedidos INT NOT NULL DEFAULT 0,
  quantidade INT NOT NULL DEFAULT 0,
  ticket_medio NUMERIC(10,2),
  
  -- KPIs de Delivery
  pedidos_delivery INT DEFAULT 0,
  tempo_medio_entrega INT,
  
  -- Metadados
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now(),
  
  PRIMARY KEY (rede_id, loja_id, data)
);

-- Índices para performance
CREATE INDEX idx_kpi_dia_rede ON kpi_vendas_dia(rede_id);
CREATE INDEX idx_kpi_dia_loja ON kpi_vendas_dia(loja_id);
CREATE INDEX idx_kpi_dia_data ON kpi_vendas_dia(data);
CREATE INDEX idx_kpi_dia_ano_mes ON kpi_vendas_dia(ano, mes);
CREATE INDEX idx_kpi_dia_semana ON kpi_vendas_dia(ano, semana_ano);

-- Comentários
COMMENT ON TABLE kpi_vendas_dia IS 'Fonte única de KPIs consolidados por dia';
COMMENT ON COLUMN kpi_vendas_dia.margem_percent IS 'Calculado: (margem / faturamento) * 100';
COMMENT ON COLUMN kpi_vendas_dia.ticket_medio IS 'Calculado: faturamento / pedidos';

✅ FONTE ÚNICA: Todas as outras consolidações (semana, mês, ano) derivam desta tabela.

3.2 KPI Semanal

07_kpi_vendas_semana.sql
CREATE TABLE kpi_vendas_semana (
  rede_id UUID NOT NULL REFERENCES redes(id) ON DELETE CASCADE,
  loja_id UUID NOT NULL REFERENCES lojas(id) ON DELETE CASCADE,
  ano INT NOT NULL,
  semana_ano INT NOT NULL,
  
  faturamento NUMERIC(14,2) NOT NULL DEFAULT 0,
  custo NUMERIC(14,2) NOT NULL DEFAULT 0,
  margem NUMERIC(14,2) NOT NULL DEFAULT 0,
  margem_percent NUMERIC(5,2),
  pedidos INT NOT NULL DEFAULT 0,
  quantidade INT NOT NULL DEFAULT 0,
  ticket_medio NUMERIC(10,2),
  
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now(),
  
  PRIMARY KEY (rede_id, loja_id, ano, semana_ano)
);

CREATE INDEX idx_kpi_semana_rede ON kpi_vendas_semana(rede_id);
CREATE INDEX idx_kpi_semana_loja ON kpi_vendas_semana(loja_id);
CREATE INDEX idx_kpi_semana_periodo ON kpi_vendas_semana(ano, semana_ano);

COMMENT ON TABLE kpi_vendas_semana IS 'Derivado de kpi_vendas_dia';

3.3 KPI Mensal

08_kpi_vendas_mes.sql
CREATE TABLE kpi_vendas_mes (
  rede_id UUID NOT NULL REFERENCES redes(id) ON DELETE CASCADE,
  loja_id UUID NOT NULL REFERENCES lojas(id) ON DELETE CASCADE,
  ano INT NOT NULL,
  mes INT NOT NULL,
  
  faturamento NUMERIC(14,2) NOT NULL DEFAULT 0,
  custo NUMERIC(14,2) NOT NULL DEFAULT 0,
  margem NUMERIC(14,2) NOT NULL DEFAULT 0,
  margem_percent NUMERIC(5,2),
  pedidos INT NOT NULL DEFAULT 0,
  quantidade INT NOT NULL DEFAULT 0,
  ticket_medio NUMERIC(10,2),
  
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now(),
  
  PRIMARY KEY (rede_id, loja_id, ano, mes)
);

CREATE INDEX idx_kpi_mes_rede ON kpi_vendas_mes(rede_id);
CREATE INDEX idx_kpi_mes_loja ON kpi_vendas_mes(loja_id);
CREATE INDEX idx_kpi_mes_periodo ON kpi_vendas_mes(ano, mes);

COMMENT ON TABLE kpi_vendas_mes IS 'Derivado de kpi_vendas_dia';

3.4 KPI Anual

09_kpi_vendas_ano.sql
CREATE TABLE kpi_vendas_ano (
  rede_id UUID NOT NULL REFERENCES redes(id) ON DELETE CASCADE,
  loja_id UUID NOT NULL REFERENCES lojas(id) ON DELETE CASCADE,
  ano INT NOT NULL,
  
  faturamento NUMERIC(14,2) NOT NULL DEFAULT 0,
  custo NUMERIC(14,2) NOT NULL DEFAULT 0,
  margem NUMERIC(14,2) NOT NULL DEFAULT 0,
  margem_percent NUMERIC(5,2),
  pedidos INT NOT NULL DEFAULT 0,
  quantidade INT NOT NULL DEFAULT 0,
  ticket_medio NUMERIC(10,2),
  
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now(),
  
  PRIMARY KEY (rede_id, loja_id, ano)
);

CREATE INDEX idx_kpi_ano_rede ON kpi_vendas_ano(rede_id);
CREATE INDEX idx_kpi_ano_loja ON kpi_vendas_ano(loja_id);
CREATE INDEX idx_kpi_ano_periodo ON kpi_vendas_ano(ano);

COMMENT ON TABLE kpi_vendas_ano IS 'Derivado de kpi_vendas_dia';

Pipeline de Consolidação

kpi_vendas_dia
kpi_vendas_semana
kpi_vendas_mes
kpi_vendas_ano

📌 Nunca pular etapas. Todas as consolidações derivam da diária.

4Segurança (RLS OBRIGATÓRIO)

4.1 Política de Isolamento por Rede

10_rls_isolamento_rede.sql
-- Habilitar RLS em todas as tabelas de KPI
ALTER TABLE kpi_vendas_dia ENABLE ROW LEVEL SECURITY;
ALTER TABLE kpi_vendas_semana ENABLE ROW LEVEL SECURITY;
ALTER TABLE kpi_vendas_mes ENABLE ROW LEVEL SECURITY;
ALTER TABLE kpi_vendas_ano ENABLE ROW LEVEL SECURITY;

-- Política: Usuário só vê dados da própria rede
CREATE POLICY isolamento_rede_dia
ON kpi_vendas_dia
FOR SELECT
USING (
  rede_id = (
    SELECT rede_id 
    FROM profiles 
    WHERE id = auth.uid()
  )
);

CREATE POLICY isolamento_rede_semana
ON kpi_vendas_semana
FOR SELECT
USING (
  rede_id = (
    SELECT rede_id 
    FROM profiles 
    WHERE id = auth.uid()
  )
);

CREATE POLICY isolamento_rede_mes
ON kpi_vendas_mes
FOR SELECT
USING (
  rede_id = (
    SELECT rede_id 
    FROM profiles 
    WHERE id = auth.uid()
  )
);

CREATE POLICY isolamento_rede_ano
ON kpi_vendas_ano
FOR SELECT
USING (
  rede_id = (
    SELECT rede_id 
    FROM profiles 
    WHERE id = auth.uid()
  )
);

🔒 SEGURANÇA SAAS REAL:

  • • Uma rede nunca vê dados de outra rede
  • • Isolamento garantido no nível do banco de dados
  • • Impossível burlar via frontend ou API

4.2 Políticas por Loja (Quando Necessário)

11_rls_isolamento_loja.sql
-- Política adicional: Gerente de loja vê apenas sua loja
CREATE POLICY isolamento_loja_dia
ON kpi_vendas_dia
FOR SELECT
USING (
  -- Admin de rede vê todas as lojas
  EXISTS (
    SELECT 1 FROM profiles 
    WHERE id = auth.uid() 
    AND rede_id = kpi_vendas_dia.rede_id
    AND perfil IN ('super_admin', 'admin_rede')
  )
  OR
  -- Gerente vê apenas sua loja
  EXISTS (
    SELECT 1 FROM profiles 
    WHERE id = auth.uid() 
    AND loja_id = kpi_vendas_dia.loja_id
    AND perfil = 'gerente_loja'
  )
);

-- Replicar para outras tabelas (semana, mês, ano)
-- [código similar para kpi_vendas_semana, kpi_vendas_mes, kpi_vendas_ano]

📌 Hierarquia de Acesso: Admin de rede vê todas as lojas, Gerente vê apenas a própria loja.

5Integração com Backend Node

5.1 Backend usa service_role

O backend Node.js usa service_role para operações privilegiadas:

  • Inserir KPIs consolidados
  • Reprocessar períodos
  • Jobs de consolidação (ETL)
backend_supabase_client.js
// Backend Node.js - Configuração do Supabase
import { createClient } from '@supabase/supabase-js';

const supabaseUrl = process.env.SUPABASE_URL;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;

// Cliente com service_role (APENAS BACKEND)
const supabase = createClient(supabaseUrl, supabaseServiceKey, {
  auth: {
    autoRefreshToken: false,
    persistSession: false
  }
});

// Exemplo: Inserir KPI diário
async function inserirKPIDiario(dados) {
  const { data, error } = await supabase
    .from('kpi_vendas_dia')
    .upsert({
      rede_id: dados.rede_id,
      loja_id: dados.loja_id,
      data: dados.data,
      ano: dados.ano,
      mes: dados.mes,
      semana_ano: dados.semana_ano,
      faturamento: dados.faturamento,
      pedidos: dados.pedidos,
      // ... outros campos
    }, {
      onConflict: 'rede_id,loja_id,data'
    });
  
  if (error) throw error;
  return data;
}

export { supabase, inserirKPIDiario };

5.2 Frontend usa JWT do Supabase

O frontend React usa anon key + JWT do usuário logado:

  • Apenas leitura de dados
  • Sempre filtrado por RLS
  • Sem acesso privilegiado
frontend_supabase_client.ts
// Frontend React - Configuração do Supabase
import { createClient } from '@supabase/supabase-js';

const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;

// Cliente com anon key (FRONTEND)
const supabase = createClient(supabaseUrl, supabaseAnonKey);

// Exemplo: Buscar KPIs (automaticamente filtrado por RLS)
async function buscarKPIsDiarios(dataInicio: string, dataFim: string) {
  const { data, error } = await supabase
    .from('kpi_vendas_dia')
    .select('*')
    .gte('data', dataInicio)
    .lte('data', dataFim)
    .order('data', { ascending: false });
  
  if (error) throw error;
  return data;
}

// Login do usuário
async function login(email: string, password: string) {
  const { data, error } = await supabase.auth.signInWithPassword({
    email,
    password
  });
  
  if (error) throw error;
  return data;
}

export { supabase, buscarKPIsDiarios, login };

⚠️ CRÍTICO: Frontend NUNCA usa service_role. Sempre usa anon key + JWT do usuário.

6Base para IA

6.1 Tabela de Insights

12_ai_insights.sql
CREATE TABLE ai_insights (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  rede_id UUID NOT NULL REFERENCES redes(id) ON DELETE CASCADE,
  loja_id UUID REFERENCES lojas(id) ON DELETE CASCADE,
  
  -- Classificação
  tipo TEXT NOT NULL CHECK (
    tipo IN ('oportunidade', 'anomalia', 'otimizacao', 'previsao', 'performance')
  ),
  severidade TEXT NOT NULL CHECK (
    severidade IN ('critica', 'alta', 'media', 'baixa')
  ),
  
  -- Conteúdo
  titulo TEXT NOT NULL,
  descricao TEXT NOT NULL,
  confianca INT CHECK (confianca BETWEEN 0 AND 100),
  
  -- Período de referência
  data_inicio DATE,
  data_fim DATE,
  
  -- Metadados
  payload_entrada JSONB,
  acao_recomendada TEXT,
  
  -- Status
  status TEXT DEFAULT 'ativo' CHECK (
    status IN ('ativo', 'arquivado', 'implementado', 'descartado')
  ),
  
  created_at TIMESTAMP DEFAULT now(),
  updated_at TIMESTAMP DEFAULT now(),
  
  -- RLS
  CONSTRAINT fk_rede FOREIGN KEY (rede_id) REFERENCES redes(id)
);

-- Índices
CREATE INDEX idx_ai_insights_rede ON ai_insights(rede_id);
CREATE INDEX idx_ai_insights_loja ON ai_insights(loja_id);
CREATE INDEX idx_ai_insights_tipo ON ai_insights(tipo);
CREATE INDEX idx_ai_insights_status ON ai_insights(status);
CREATE INDEX idx_ai_insights_created ON ai_insights(created_at DESC);

-- RLS
ALTER TABLE ai_insights ENABLE ROW LEVEL SECURITY;

CREATE POLICY isolamento_rede_insights
ON ai_insights
FOR SELECT
USING (
  rede_id = (
    SELECT rede_id 
    FROM profiles 
    WHERE id = auth.uid()
  )
);

COMMENT ON TABLE ai_insights IS 'Insights gerados pela IA';
COMMENT ON COLUMN ai_insights.confianca IS 'Nível de confiança do insight (0-100)';
COMMENT ON COLUMN ai_insights.payload_entrada IS 'JSON com dados usados para gerar o insight';

🤖 Regras da IA:

  • • IA só lê dados consolidados (kpi_vendas_*)
  • • IA só escreve insights (ai_insights)
  • • IA nunca acessa banco legado MySQL
  • • IA nunca modifica KPIs

7Operação e Governança

7.1 Logs de Operação

13_logs_operacao.sql
CREATE TABLE etl_logs (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  rede_id UUID NOT NULL REFERENCES redes(id),
  loja_id UUID REFERENCES lojas(id),
  
  -- Tipo de operação
  operacao TEXT NOT NULL CHECK (
    operacao IN ('ingestao', 'consolidacao', 'reprocessamento')
  ),
  
  -- Período processado
  data_inicio DATE,
  data_fim DATE,
  
  -- Status
  status TEXT NOT NULL CHECK (
    status IN ('iniciado', 'sucesso', 'falha', 'parcial')
  ),
  
  -- Métricas
  registros_processados INT,
  registros_inseridos INT,
  registros_atualizados INT,
  registros_rejeitados INT,
  
  -- Detalhes
  mensagem TEXT,
  erro_detalhes JSONB,
  
  -- Timestamps
  inicio_em TIMESTAMP NOT NULL,
  fim_em TIMESTAMP,
  duracao_segundos INT,
  
  created_at TIMESTAMP DEFAULT now()
);

CREATE INDEX idx_etl_logs_rede ON etl_logs(rede_id);
CREATE INDEX idx_etl_logs_loja ON etl_logs(loja_id);
CREATE INDEX idx_etl_logs_status ON etl_logs(status);
CREATE INDEX idx_etl_logs_operacao ON etl_logs(operacao);
CREATE INDEX idx_etl_logs_created ON etl_logs(created_at DESC);

COMMENT ON TABLE etl_logs IS 'Logs de operações de ETL e consolidação';

7.2 Monitoramento

Volume por Loja

Monitorar quantidade de registros consolidados por loja/dia

Última Consolidação

Verificar data da última consolidação bem-sucedida

Falhas de Ingestão

Alertar sobre falhas consecutivas de ingestão

Checklist READY (GO / NO-GO)

Supabase criado

Projeto configurado com região correta

Multi-tenant definido

Tabelas redes, lojas, profiles criadas

Dimensão tempo populada

dim_tempo com 10 anos de dados

KPIs consolidados criados

Dia, semana, mês, ano implementados

RLS ativo

Políticas de isolamento por rede/loja

Backend autorizado

service_role configurado no Node.js

Frontend isolado

anon key + JWT, sem service_role

Base pronta para IA

Tabela ai_insights criada

Se todos os itens = ✅ → PRODUÇÃO

A base principal do DBCHECKOUT está pronta para receber dados consolidados e alimentar o dashboard analítico.