Neste projeto aplicado, você vai construir um conjunto de análises de vendas que respondem perguntas recorrentes do dia a dia: quanto faturamos (receita), qual é o ticket médio, como está o mix de produtos (participação por categoria/sku) e como a sazonalidade afeta o desempenho ao longo do tempo. A proposta é sair de métricas isoladas e montar um “pacote” de consultas que se conectam: primeiro garantimos uma base confiável de vendas, depois derivamos indicadores e, por fim, detalhamos o que explica as variações (mix e sazonalidade).
Escopo do projeto e perguntas de negócio
Você vai produzir consultas que respondem, no mínimo, a estas perguntas:
- Receita: qual foi a receita bruta e a receita líquida por período?
- Ticket médio: quanto, em média, cada pedido gerou de receita?
- Mix de produtos: quais categorias e produtos mais contribuem para a receita e para a quantidade vendida?
- Sazonalidade: quais meses/semanas/dias têm picos e vales? Existe padrão por dia da semana?
- Diagnóstico: quando a receita muda, foi por volume (número de pedidos), por preço (ticket) ou por mudança de mix?
Para manter o projeto prático, vamos assumir um modelo transacional típico de e-commerce/PDV com pedidos e itens. Ajuste os nomes das tabelas/colunas conforme seu banco.
Modelo de dados mínimo (referência)
Considere as tabelas abaixo como referência conceitual:
- orders: order_id, customer_id, order_date, status, channel, store_id
- order_items: order_id, product_id, quantity, unit_price, discount_amount
- products: product_id, product_name, category, brand
- returns (opcional): order_id, product_id, return_qty, return_amount
O ponto central do projeto é transformar linhas de itens em métricas de pedido e de período. Em análises de receita e ticket, a unidade de análise precisa estar clara: receita pode ser somada em nível de item; ticket médio normalmente é receita por pedido (não por item).
Continue em nosso aplicativo
Você poderá ouvir o audiobook com a tela desligada, ganhar gratuitamente o certificado deste curso e ainda ter acesso a outros 5.000 cursos online gratuitos.
ou continue lendo abaixo...Baixar o aplicativo
Passo a passo 1: construir a base de vendas (fato de itens)
Comece criando uma visão lógica (ou CTE) que calcule o valor do item e normalize o que será considerado receita. Uma prática comum é separar:
- Receita bruta: quantity * unit_price
- Desconto: discount_amount (por item ou rateado)
- Receita líquida: receita bruta - desconto - devoluções (se aplicável)
Exemplo (sem devoluções):
WITH item_sales AS ( SELECT oi.order_id, o.order_date, o.status, o.channel, oi.product_id, oi.quantity, oi.unit_price, COALESCE(oi.discount_amount, 0) AS discount_amount, (oi.quantity * oi.unit_price) AS gross_revenue, (oi.quantity * oi.unit_price) - COALESCE(oi.discount_amount, 0) AS net_revenue FROM order_items oi JOIN orders o ON o.order_id = oi.order_id WHERE o.status IN ('paid','shipped','delivered') ) SELECT * FROM item_sales;Notas práticas:
- Defina claramente quais status entram como venda (ex.: excluir cancelados e pendentes).
- Se desconto estiver em nível de pedido, você precisará ratear pelos itens (por proporção do valor do item). Isso muda o cálculo e deve ser documentado.
- Se existir devolução, trate como ajuste negativo de receita (ou crie receita líquida “pós-devolução”).
Passo a passo 2: transformar itens em métricas por pedido (base para ticket)
Ticket médio exige consolidar itens por pedido. Crie uma segunda camada agregando por order_id. Isso também ajuda a separar “quantidade de pedidos” de “quantidade de itens”.
WITH item_sales AS ( SELECT oi.order_id, o.order_date, o.channel, oi.product_id, oi.quantity, (oi.quantity * oi.unit_price) AS gross_revenue, COALESCE(oi.discount_amount, 0) AS discount_amount, (oi.quantity * oi.unit_price) - COALESCE(oi.discount_amount, 0) AS net_revenue FROM order_items oi JOIN orders o ON o.order_id = oi.order_id WHERE o.status IN ('paid','shipped','delivered') ), order_sales AS ( SELECT order_id, MIN(order_date) AS order_date, MIN(channel) AS channel, SUM(gross_revenue) AS gross_revenue, SUM(discount_amount) AS discount_amount, SUM(net_revenue) AS net_revenue, SUM(quantity) AS items_qty, COUNT(DISTINCT product_id) AS distinct_products FROM item_sales GROUP BY order_id ) SELECT * FROM order_sales;O que você ganha com essa camada:
- Ticket fica direto: net_revenue por pedido.
- Você consegue medir complexidade do carrinho: itens_qty e distinct_products.
- Você pode segmentar por canal/loja sem duplicar pedidos (desde que channel seja consistente por pedido).
Receita por período (diária, semanal, mensal)
Com a base por pedido, você pode gerar relatórios de receita por período. A granularidade depende do uso: diário para operação, semanal para acompanhamento tático, mensal para planejamento.
Receita e pedidos por mês
WITH order_sales AS ( SELECT o.order_id, o.order_date, o.channel, SUM(oi.quantity * oi.unit_price) - SUM(COALESCE(oi.discount_amount,0)) AS net_revenue FROM orders o JOIN order_items oi ON oi.order_id = o.order_id WHERE o.status IN ('paid','shipped','delivered') GROUP BY o.order_id, o.order_date, o.channel ) SELECT DATE_TRUNC('month', order_date) AS month, SUM(net_revenue) AS revenue, COUNT(*) AS orders, SUM(net_revenue) / NULLIF(COUNT(*),0) AS avg_ticket FROM order_sales GROUP BY 1 ORDER BY 1;Interpretação:
- revenue mostra o total do período.
- orders mostra volume.
- avg_ticket ajuda a entender se a receita subiu por mais pedidos ou por pedidos maiores.
Se seu banco não suportar DATE_TRUNC, substitua por funções equivalentes (por exemplo, construir “ano-mês” com YEAR/MONTH ou FORMAT).
Ticket médio: variações e segmentações úteis
Ticket médio pode ser enganoso se você misturar canais, lojas ou tipos de pedido. Em vez de um único número, crie cortes que façam sentido operacional.
Ticket por canal
WITH order_sales AS ( SELECT o.order_id, o.order_date, o.channel, SUM(oi.quantity * oi.unit_price) - SUM(COALESCE(oi.discount_amount,0)) AS net_revenue FROM orders o JOIN order_items oi ON oi.order_id = o.order_id WHERE o.status IN ('paid','shipped','delivered') GROUP BY o.order_id, o.order_date, o.channel ) SELECT channel, COUNT(*) AS orders, SUM(net_revenue) AS revenue, SUM(net_revenue) / NULLIF(COUNT(*),0) AS avg_ticket FROM order_sales GROUP BY channel ORDER BY revenue DESC;Uso prático:
- Se o ticket do canal “app” cair, pode indicar mais compras de reposição (mix mais barato) ou promoções.
- Se o ticket do canal “loja” subir, pode ser efeito de upsell ou mudança no sortimento.
Distribuição de ticket (faixas)
Média esconde dispersão. Uma análise simples é criar faixas de ticket para ver concentração.
WITH order_sales AS ( SELECT o.order_id, SUM(oi.quantity * oi.unit_price) - SUM(COALESCE(oi.discount_amount,0)) AS net_revenue FROM orders o JOIN order_items oi ON oi.order_id = o.order_id WHERE o.status IN ('paid','shipped','delivered') GROUP BY o.order_id ), bucketed AS ( SELECT order_id, net_revenue, CASE WHEN net_revenue < 50 THEN '0-49' WHEN net_revenue < 100 THEN '50-99' WHEN net_revenue < 200 THEN '100-199' WHEN net_revenue < 500 THEN '200-499' ELSE '500+' END AS ticket_bucket FROM order_sales ) SELECT ticket_bucket, COUNT(*) AS orders, SUM(net_revenue) AS revenue FROM bucketed GROUP BY ticket_bucket ORDER BY orders DESC;Isso ajuda a responder: a receita vem de muitos pedidos pequenos ou poucos pedidos grandes?
Mix de produtos: participação por categoria e por produto
“Mix” é a composição das vendas. Você pode olhar mix por:
- Receita: participação no faturamento.
- Quantidade: participação em unidades.
- Pedidos: em quantos pedidos o item aparece (penetração).
O cuidado aqui é não confundir “quantidade de itens” com “quantidade de pedidos com aquele item”. Para penetração, conte pedidos distintos.
Mix por categoria (receita e unidades)
WITH item_sales AS ( SELECT o.order_date, p.category, oi.product_id, oi.quantity, (oi.quantity * oi.unit_price) - COALESCE(oi.discount_amount,0) AS net_revenue FROM orders o JOIN order_items oi ON oi.order_id = o.order_id JOIN products p ON p.product_id = oi.product_id WHERE o.status IN ('paid','shipped','delivered') ) , totals AS ( SELECT SUM(net_revenue) AS total_revenue, SUM(quantity) AS total_units FROM item_sales ) SELECT category, SUM(net_revenue) AS revenue, SUM(quantity) AS units, SUM(net_revenue) / NULLIF((SELECT total_revenue FROM totals),0) AS revenue_share, SUM(quantity) / NULLIF((SELECT total_units FROM totals),0) AS unit_share FROM item_sales GROUP BY category ORDER BY revenue DESC;Leituras comuns:
- Categoria com revenue_share alto e unit_share baixo tende a ter preço médio maior.
- Categoria com unit_share alto e revenue_share baixo pode ser “geradora de tráfego” (itens baratos e frequentes).
Top produtos por receita com participação acumulada (curva ABC)
Uma forma prática de priorizar é medir quanto do faturamento vem dos produtos líderes e qual a concentração.
WITH item_sales AS ( SELECT oi.product_id, SUM((oi.quantity * oi.unit_price) - COALESCE(oi.discount_amount,0)) AS revenue FROM orders o JOIN order_items oi ON oi.order_id = o.order_id WHERE o.status IN ('paid','shipped','delivered') GROUP BY oi.product_id ), ranked AS ( SELECT product_id, revenue, SUM(revenue) OVER () AS total_revenue, SUM(revenue) OVER (ORDER BY revenue DESC) AS cum_revenue FROM item_sales ) SELECT product_id, revenue, revenue / NULLIF(total_revenue,0) AS revenue_share, cum_revenue / NULLIF(total_revenue,0) AS cum_share, CASE WHEN (cum_revenue / NULLIF(total_revenue,0)) <= 0.80 THEN 'A' WHEN (cum_revenue / NULLIF(total_revenue,0)) <= 0.95 THEN 'B' ELSE 'C' END AS abc_class FROM ranked ORDER BY revenue DESC;Aplicações:
- Classe A: foco em disponibilidade, preço e campanhas.
- Classe C: cauda longa; pode ser importante para variedade, mas com menor impacto no faturamento.
Penetração: produtos mais presentes nos pedidos
Um produto pode não ser top em receita, mas aparecer em muitos pedidos (alto “attach rate”). Isso é útil para cross-sell e layout de loja.
WITH item_orders AS ( SELECT oi.product_id, oi.order_id FROM orders o JOIN order_items oi ON oi.order_id = o.order_id WHERE o.status IN ('paid','shipped','delivered') GROUP BY oi.product_id, oi.order_id ), totals AS ( SELECT COUNT(DISTINCT order_id) AS total_orders FROM orders WHERE status IN ('paid','shipped','delivered') ) SELECT product_id, COUNT(*) AS orders_with_product, COUNT(*) / NULLIF((SELECT total_orders FROM totals),0) AS penetration FROM item_orders GROUP BY product_id ORDER BY orders_with_product DESC;Sazonalidade: como medir e comparar períodos
Sazonalidade é a repetição de padrões ao longo do tempo (mês, semana, dia da semana, feriados). Para análise prática, você quer:
- Identificar picos e vales por período.
- Comparar o período atual com um período anterior equivalente (mês contra mês anterior, ou mesmo mês do ano anterior).
- Separar efeito de volume (pedidos) e efeito de ticket.
Sazonalidade mensal: receita, pedidos e ticket lado a lado
WITH order_sales AS ( SELECT o.order_id, o.order_date, SUM(oi.quantity * oi.unit_price) - SUM(COALESCE(oi.discount_amount,0)) AS net_revenue FROM orders o JOIN order_items oi ON oi.order_id = o.order_id WHERE o.status IN ('paid','shipped','delivered') GROUP BY o.order_id, o.order_date ), monthly AS ( SELECT DATE_TRUNC('month', order_date) AS month, SUM(net_revenue) AS revenue, COUNT(*) AS orders, SUM(net_revenue) / NULLIF(COUNT(*),0) AS avg_ticket FROM order_sales GROUP BY 1 ) SELECT month, revenue, orders, avg_ticket FROM monthly ORDER BY month;Esse relatório é a “espinha dorsal” da sazonalidade. A partir dele, você pode aprofundar com comparações e decomposições.
Índice sazonal: mês como % da média do ano
Um jeito simples de ver sazonalidade é comparar cada mês com a média mensal do ano (ou da série). Isso cria um índice: acima de 1 indica mês forte; abaixo de 1 indica mês fraco.
WITH order_sales AS ( SELECT o.order_id, o.order_date, SUM(oi.quantity * oi.unit_price) - SUM(COALESCE(oi.discount_amount,0)) AS net_revenue FROM orders o JOIN order_items oi ON oi.order_id = o.order_id WHERE o.status IN ('paid','shipped','delivered') GROUP BY o.order_id, o.order_date ), monthly AS ( SELECT DATE_TRUNC('month', order_date) AS month, EXTRACT(YEAR FROM order_date) AS year, EXTRACT(MONTH FROM order_date) AS month_num, SUM(net_revenue) AS revenue FROM order_sales GROUP BY 1,2,3 ), yearly_avg AS ( SELECT year, AVG(revenue) AS avg_month_revenue FROM monthly GROUP BY year ) SELECT m.year, m.month_num, m.revenue, m.revenue / NULLIF(y.avg_month_revenue,0) AS seasonal_index FROM monthly m JOIN yearly_avg y ON y.year = m.year ORDER BY m.year, m.month_num;Uso prático:
- Planejamento de estoque e metas: meses com índice 1,3 tendem a exigir mais capacidade.
- Comparações justas: não comparar um mês historicamente fraco com um mês historicamente forte sem ajustar expectativa.
Sazonalidade por dia da semana
Para operação, dia da semana costuma ser mais acionável do que mês. Você pode medir receita média por dia da semana e também pedidos.
WITH order_sales AS ( SELECT o.order_id, o.order_date, SUM(oi.quantity * oi.unit_price) - SUM(COALESCE(oi.discount_amount,0)) AS net_revenue FROM orders o JOIN order_items oi ON oi.order_id = o.order_id WHERE o.status IN ('paid','shipped','delivered') GROUP BY o.order_id, o.order_date ) SELECT EXTRACT(DOW FROM order_date) AS dow, COUNT(*) AS orders, SUM(net_revenue) AS revenue, AVG(net_revenue) AS avg_ticket FROM order_sales GROUP BY 1 ORDER BY 1;Observação: o mapeamento de DOW varia por banco (domingo=0 ou segunda=1). Ajuste a interpretação e, se necessário, crie uma tabela de apoio para nomes dos dias.
Diagnóstico de variação: receita mudou por volume, ticket ou mix?
Quando a receita sobe ou cai, as causas mais comuns são:
- Volume: mais/menos pedidos.
- Ticket: pedidos maiores/menores (preço, desconto, quantidade por pedido).
- Mix: participação maior de categorias caras/baratas.
Uma decomposição simples e muito usada no dia a dia é colocar lado a lado, por período, receita, pedidos e ticket. Se a receita caiu e os pedidos ficaram estáveis, o ticket caiu. Se o ticket ficou estável e os pedidos caíram, é volume. Se ambos mudaram pouco, mas categorias mudaram bastante, é mix.
Relatório mensal com mix por categoria (para explicar mudanças)
WITH item_sales AS ( SELECT DATE_TRUNC('month', o.order_date) AS month, p.category, (oi.quantity * oi.unit_price) - COALESCE(oi.discount_amount,0) AS net_revenue, oi.quantity AS units FROM orders o JOIN order_items oi ON oi.order_id = o.order_id JOIN products p ON p.product_id = oi.product_id WHERE o.status IN ('paid','shipped','delivered') ), month_totals AS ( SELECT month, SUM(net_revenue) AS total_revenue FROM item_sales GROUP BY month ) SELECT s.month, s.category, SUM(s.net_revenue) AS revenue, SUM(s.units) AS units, SUM(s.net_revenue) / NULLIF(t.total_revenue,0) AS revenue_share FROM item_sales s JOIN month_totals t ON t.month = s.month GROUP BY s.month, s.category, t.total_revenue ORDER BY s.month, revenue DESC;Como usar para diagnóstico:
- Compare meses consecutivos: se a receita caiu, veja se a revenue_share migrou para categorias mais baratas.
- Se a participação de uma categoria de alto valor caiu, o ticket tende a cair mesmo com pedidos estáveis.
- Se unidades subiram mas receita não, pode haver aumento de descontos ou troca para itens mais baratos.
Checklist de entrega do projeto (consultas finais)
Ao final, você deve ter um conjunto de consultas reutilizáveis:
- Base de itens com receita bruta, desconto e receita líquida (item_sales).
- Base por pedido com receita líquida e contagens (order_sales).
- Receita, pedidos e ticket por mês (e por canal, se aplicável).
- Mix por categoria com participação no faturamento e em unidades.
- Top produtos por receita com participação acumulada (ABC).
- Penetração por produto (pedidos com o produto / total de pedidos).
- Sazonalidade: índice mensal e padrão por dia da semana.
- Relatório mensal de mix por categoria para explicar variações de receita.
Esse pacote cobre o essencial para relatórios recorrentes e também para investigações rápidas quando “algo mudou” nas vendas. A partir daqui, você consegue plugar filtros por canal, loja, região, campanha e criar versões comparáveis por período, mantendo a mesma lógica de base.