Logo

Command Palette

Search for a command to run...

Command Palette

Search for a command to run...

Blog

Como modelei um funil de vendas flexível no PostgreSQL

O modelo de dados de um CRM não é óbvio. As 4 iterações até acertar o histórico, snapshots e consistência no banco.

Schema do funil de vendas no PostgreSQL

Quando comecei a construir o Axxos CRM, assumi que a parte mais difícil seria o frontend — o pipeline visual estilo Kanban, os drag-and-drops, os gráficos em tempo real. Estava errado. A parte mais difícil foi modelar o funil de vendas no banco de dados.

Parece simples: um lead passa por etapas até virar cliente. Mas quando você começa a pensar nas perguntas reais do negócio — um lead pode estar em mais de uma etapa? O que acontece quando a etapa é renomeada? Como rastrear quanto tempo ficou em cada fase? — a complexidade aparece rápido.

Foram quatro iterações até chegar num modelo que funcionasse bem em produção. Aqui estão todas elas, com os erros e o que cada uma me ensinou.

O contexto

O Axxos CRM precisava suportar o ciclo de vida completo de um lead comercial — desde o primeiro contato até o fechamento da venda e o pós-venda. O time tinha necessidades específicas:

  • Etapas do funil configuráveis (sem deploy para mudar)
  • Histórico completo de movimentação de cada lead
  • Relatórios de tempo médio por etapa e taxa de conversão
  • Múltiplos usuários operando simultaneamente sem conflito
  • Auditoria de quem moveu o quê e quando

O que parecia ser um simples status num campo da tabela rapidamente mostrou que não seria suficiente.

Iteração 1 — O enum ingênuo

A primeira versão foi a mais óbvia. Um campo status na tabela de leads com um enum fixo no banco:

CREATE TYPE lead_status AS ENUM (
  'novo',
  'contato_feito',
  'proposta_enviada',
  'negociacao',
  'fechado',
  'perdido'
);
 
CREATE TABLE leads (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  nome        TEXT NOT NULL,
  email       TEXT,
  status      lead_status NOT NULL DEFAULT 'novo',
  created_at  TIMESTAMPTZ DEFAULT NOW(),
  updated_at  TIMESTAMPTZ DEFAULT NOW()
);

Por que pareceu certo: simples, rápido de implementar, queries diretas. WHERE status = 'proposta_enviada' funciona perfeitamente.

Por que não funcionou:

O problema apareceu na primeira reunião com o time comercial. Eles queriam adicionar uma etapa "Demonstração Agendada" entre contato e proposta. Com enum, isso significa uma migration em produção, um deploy e torcer para ninguém estar usando o sistema naquele momento.

Pior: enums no PostgreSQL não permitem remover valores sem recriar o tipo. Se uma etapa cai em desuso, ela fica lá para sempre.

O enum fixo amarra o banco ao processo de negócio. E processo de negócio muda.

Iteração 2 — A tabela de etapas

A solução natural foi extrair as etapas para uma tabela separada:

CREATE TABLE pipeline_stages (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  nome       TEXT NOT NULL,
  ordem      INTEGER NOT NULL,
  cor        TEXT,
  created_at TIMESTAMPTZ DEFAULT NOW()
);
 
CREATE TABLE leads (
  id         UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  nome       TEXT NOT NULL,
  email      TEXT,
  stage_id   UUID REFERENCES pipeline_stages(id),
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

Agora o time comercial pode criar, renomear e reordenar etapas sem nenhum deploy. Melhor.

Por que não funcionou:

Dois problemas apareceram juntos.

O primeiro: sem histórico. Quando um lead muda de etapa, o stage_id é sobrescrito. Não há como saber quanto tempo ficou em cada fase, quem fez a mudança ou qual era a etapa anterior. Isso tornava relatórios de tempo médio por etapa impossíveis.

O segundo — mais sutil — apareceu quando um lead foi excluído de uma etapa por engano. Não havia como desfazer. A operação era destrutiva.

Qualquer sistema de vendas real precisa de auditoria. Sem ela, o time perde confiança nos dados rapidamente.

Iteração 3 — A tabela de histórico

Adicionei uma tabela de histórico para registrar cada movimentação:

CREATE TABLE lead_stage_history (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  lead_id      UUID NOT NULL REFERENCES leads(id),
  stage_id     UUID NOT NULL REFERENCES pipeline_stages(id),
  user_id      UUID NOT NULL REFERENCES users(id),
  entered_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  exited_at    TIMESTAMPTZ
);

A lógica ficou assim: quando um lead muda de etapa, o registro atual recebe exited_at = NOW() e um novo registro é inserido com entered_at = NOW() e exited_at = NULL. A etapa atual é sempre o registro sem exited_at.

Agora dava para calcular tempo por etapa, rastrear quem moveu, ver o histórico completo.

Por que ainda não estava certo:

O modelo funcionava para movimentações simples, mas tinha uma falha de consistência: o stage_id na tabela leads e o registro atual em lead_stage_history podiam ficar dessincronizados se uma das operações falhasse no meio. Era necessário atualizar dois lugares atomicamente toda vez.

Resolvi com transações, mas ficou frágil. A fonte da verdade estava duplicada — e duplicação de fonte da verdade é um problema esperando para acontecer.

O outro problema: e se a etapa fosse deletada? Todos os históricos que referenciavam aquela etapa ficariam órfãos, ou eu precisaria de ON DELETE RESTRICT que bloqueava a deleção de etapas que já tinham leads.

Iteração 4 — O modelo que foi para produção

A versão final resolveu os dois problemas: eliminou a duplicação de fonte da verdade e protegeu o histórico contra deleções.

-- Etapas do funil (configuráveis pelo usuário)
CREATE TABLE pipeline_stages (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  nome         TEXT NOT NULL,
  ordem        INTEGER NOT NULL,
  cor          TEXT NOT NULL DEFAULT '#6366f1',
  is_terminal  BOOLEAN NOT NULL DEFAULT FALSE, -- fechado ou perdido
  archived_at  TIMESTAMPTZ,                    -- soft delete
  created_at   TIMESTAMPTZ DEFAULT NOW()
);
 
-- Leads
CREATE TABLE leads (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  nome         TEXT NOT NULL,
  email        TEXT,
  telefone     TEXT,
  empresa      TEXT,
  responsavel  UUID REFERENCES users(id),
  created_at   TIMESTAMPTZ DEFAULT NOW()
  -- sem stage_id aqui — a etapa atual vem do histórico
);
 
-- Histórico de movimentações (fonte única da verdade)
CREATE TABLE lead_stage_history (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  lead_id      UUID NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
  stage_id     UUID NOT NULL REFERENCES pipeline_stages(id),
  -- snapshot do nome da etapa no momento da movimentação
  stage_nome   TEXT NOT NULL,
  moved_by     UUID NOT NULL REFERENCES users(id),
  entered_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  exited_at    TIMESTAMPTZ,
 
  CONSTRAINT apenas_uma_etapa_ativa
    EXCLUDE USING gist (
      lead_id WITH =,
      tstzrange(entered_at, exited_at) WITH &&
    )
);
 
-- Índices para queries de dashboard
CREATE INDEX idx_lead_stage_history_lead_id ON lead_stage_history(lead_id);
CREATE INDEX idx_lead_stage_history_stage_id ON lead_stage_history(stage_id);
CREATE INDEX idx_lead_stage_history_ativa ON lead_stage_history(lead_id) WHERE exited_at IS NULL;

As três decisões que fizeram diferença:

1. Remover stage_id de leads. A etapa atual agora é sempre consultada via WHERE exited_at IS NULL. Uma fonte de verdade, sem risco de dessincronização. A query fica um join a mais, mas com o índice parcial em exited_at IS NULL isso é imperceptível.

2. stage_nome como snapshot. Quando uma etapa é renomeada, o histórico antigo preserva o nome que ela tinha no momento da movimentação. Isso é importante para relatórios históricos — você quer saber que o lead passou por "Proposta Enviada", não por "Proposta", que é como a etapa foi renomeada depois.

3. archived_at em vez de DELETE. Etapas nunca são deletadas. São arquivadas. Isso protege o histórico e resolve o problema de ON DELETE RESTRICT que bloqueava a gestão do funil.

A constraint de exclusão garante que um lead nunca esteja em dois lugares ao mesmo tempo — sem depender de lógica na aplicação.

As queries que o dashboard usa

Com esse modelo, as queries de relatório ficaram limpas:

-- Etapa atual de cada lead (para o Kanban)
SELECT
  l.id,
  l.nome,
  l.empresa,
  h.stage_id,
  h.stage_nome,
  h.entered_at
FROM leads l
JOIN lead_stage_history h ON h.lead_id = l.id AND h.exited_at IS NULL
ORDER BY h.entered_at DESC;
 
-- Tempo médio por etapa (para relatórios de funil)
SELECT
  stage_nome,
  AVG(
    EXTRACT(EPOCH FROM (COALESCE(exited_at, NOW()) - entered_at)) / 3600
  ) AS media_horas,
  COUNT(*) AS total_passagens
FROM lead_stage_history
WHERE entered_at >= NOW() - INTERVAL '90 days'
GROUP BY stage_nome
ORDER BY media_horas DESC;
 
-- Taxa de conversão por etapa
SELECT
  stage_nome,
  COUNT(*) AS total,
  COUNT(exited_at) AS saiu,
  ROUND(COUNT(exited_at)::NUMERIC / COUNT(*) * 100, 1) AS taxa_saida_pct
FROM lead_stage_history
GROUP BY stage_nome
ORDER BY MIN(
  SELECT ordem FROM pipeline_stages WHERE nome = stage_nome LIMIT 1
);

Métricas de Crescimento

Tempo de desenvolvimento por iteração

4iterações
300%
It. 1It. 3It. 4

Complexidade vs Flexibilidade

4iteração final
350%
It. 1It. 3It. 4

Stack técnica

01

PostgreSQL

Banco principal. A constraint de exclusão com gist e os índices parciais foram decisivos para performance e consistência sem depender de lógica na aplicação.

02

Prisma ORM

Usado para as queries do dia a dia. As queries de relatório mais complexas foram escritas em SQL puro via prisma.$queryRaw para ter controle total.

03

NestJS

A camada de serviço encapsula toda a lógica de movimentação em transações atômicas — garantindo que entered_at e exited_at sempre sejam atualizados juntos.

04

Redis

Cache das métricas de dashboard (tempo médio por etapa, taxa de conversão) com TTL de 5 minutos. Evita recalcular agregações pesadas a cada requisição.

O que eu faria diferente

Teria chegado na iteração 4 mais rápido. O padrão de histórico com snapshot é conhecido — chama-se "append-only log" ou "event sourcing lite" dependendo de quem você perguntar. Se eu tivesse pesquisado antes de implementar, teria pulado as iterações 1 e 2 diretamente.

Teria adicionado metadata desde o início. Um campo JSONB metadata na tabela de histórico permite registrar contexto extra sem migrations — o motivo da movimentação, uma observação do consultor, dados da integração que triggerou a mudança. Adicionei depois e foi uma migration simples, mas teria sido mais limpo ter desde o começo.

A lição mais importante: modelo de dados é onde você paga mais caro por decisões erradas. Frontend errado você refatora em dias. Modelo de dados errado em produção, com dados reais, é uma cirurgia — e às vezes sem anestesia. Vale a pena iterar no papel antes de iterar no banco.