Pular para o conteúdo

Um projeto completo

Análise de vendas de ponta a ponta: banco SQLite, ETL, agregação, painel com filtros, exportação e testes.

Um sistema de análise de vendas, do arquivo bruto ao painel no navegador. Nada de pseudocódigo: cada bloco desta página compila, e o projeto inteiro está em projetos/painel-vendas/ — com testes que rodam em dataforge test.

text
painel-vendas/
├── forge.toml
├── dados/
│   └── vendas-2026.csv
├── src/
│   ├── esquema.df      as tabelas e as migrações
│   ├── etl.df          do CSV para o banco
│   ├── consultas.df    o SQL, num lugar só
│   └── painel.df       a página
├── main.df             sobe o servidor
└── tests/
    ├── etl_test.df
    ├── consultas_test.df
    └── painel_test.df

1. O esquema#

Migrações desde a primeira linha. Num sistema que vai receber dado todo dia, trocar o create_table no código não muda a tabela que já existe.

dataforge
// src/esquema.df
adopt Arcane.Database as Banco

steady MIGRACOES := [
    {
        "version": 1,
        "description": "vendas",
        "up": """
            CREATE TABLE vendas (
                id         INTEGER PRIMARY KEY AUTOINCREMENT,
                data       TEXT    NOT NULL,
                mes        TEXT    NOT NULL,
                regiao     TEXT    NOT NULL,
                categoria  TEXT    NOT NULL,
                vendedor   TEXT    NOT NULL,
                produto    TEXT    NOT NULL,
                quantidade INTEGER NOT NULL CHECK (quantidade > 0),
                valor      REAL    NOT NULL CHECK (valor >= 0),
                UNIQUE (data, vendedor, produto)
            );
            CREATE INDEX idx_vendas_mes    ON vendas(mes);
            CREATE INDEX idx_vendas_regiao ON vendas(regiao);
        """,
        "down": "DROP TABLE vendas;"
    },
    {
        "version": 2,
        "description": "metas por regiao e mes",
        "up": """
            CREATE TABLE metas (
                regiao TEXT NOT NULL,
                mes    TEXT NOT NULL,
                alvo   REAL NOT NULL,
                PRIMARY KEY (regiao, mes)
            );
        """,
        "down": "DROP TABLE metas;"
    }
]

action abrir(caminho):
    db := Banco.connect(caminho)
    Banco.migrate(db, MIGRACOES)
    yield db

relay abrir, MIGRACOES

2. ETL — do arquivo para o banco#

dataforge
// src/etl.df
adopt Arcane.Database as Banco
adopt Arcane.IO as IO

//: Quantas linhas por transação. Uma transação por linha faz uma
//: sincronização de disco por linha; uma transação para tudo mantém a
//: escrita presa até o fim, e um erro na linha 90.000 desfaz as 89.999
//: que estavam certas.
steady LOTE := 500

action limpar(linha):
    yield {
        "data": linha["data"].trim(),
        "mes": linha["data"].trim()[0:7],
        "regiao": linha["regiao"].trim().title(),
        "categoria": linha["categoria"].trim().lower(),
        "vendedor": linha["vendedor"].trim().lower(),
        "produto": linha["produto"].trim(),
        "quantidade": int(linha["quantidade"]),
        "valor": round(float(linha["valor"].replace(",", ".")), 2)
    }

action importar(db, caminho):
    brutas := IO.read_csv(caminho)
    limpas := []
    recusadas := []

    cycle linha in brutas:
        monitor:
            limpas.append(limpar(linha))
        handle Error as e:
            // Uma linha ruim não derruba a importação, e não é perdida
            // em silêncio: ela sai no relatório, com o motivo.
            recusadas.append({"linha": linha, "motivo": e.message})

    gravadas := 0
    cycle inicio in range(0, len(limpas), LOTE):
        lote := limpas[inicio:inicio + LOTE]
        r := Banco.upsert_many(db, "vendas", lote,
                               ["data", "vendedor", "produto"])
        gravadas += r["inseridos"] + r["atualizados"]

    yield {"lidas": len(brutas), "gravadas": gravadas,
           "recusadas": recusadas}

relay importar, limpar

Três decisões que fazem a diferença num ETL que roda todo dia:

DecisãoPor quê
upsert_many com a chave naturalreprocessar o mesmo arquivo não duplica
lote de 500, e não linha a linhauma transação por linha é uma sincronização de disco por linha; uma transação só prende a escrita até o fim
linha ruim vai para recusadasnão derruba a importação, e não desaparece

3. As consultas, num lugar só#

dataforge
// src/consultas.df
adopt Arcane.Database as Banco
adopt Arcane.Vitrine as V

//: O cache é por argumento. O programa de um painel roda INTEIRO a
//: cada clique: sem ele, mover um deslizante refaz toda agregação.
mark @V.cache(validade := 120, teto := 64)
action receita_por_mes(db, regiao):
    onde := {} given regiao is "Todas" otherwise {"regiao": regiao}
    yield Banco.aggregate(db, "vendas",
                          {"receita": ["sum", "valor"],
                           "itens": ["sum", "quantidade"],
                           "vendas": ["count", "*"]},
                          group_by := "mes", where := onde,
                          order_by := "mes")

mark @V.cache(validade := 120)
action ranking_de_vendedores(db, regiao, quantos):
    onde := {} given regiao is "Todas" otherwise {"regiao": regiao}
    yield Banco.aggregate(db, "vendas",
                          {"receita": ["sum", "valor"],
                           "ticket": ["avg", "valor"]},
                          group_by := "vendedor", where := onde,
                          order_by := "receita DESC", limit := quantos)

mark @V.cache(validade := 120)
action mix_por_categoria(db, regiao):
    onde := {} given regiao is "Todas" otherwise {"regiao": regiao}
    yield Banco.aggregate(db, "vendas", {"receita": ["sum", "valor"]},
                          group_by := "categoria", where := onde,
                          order_by := "receita DESC")

mark @V.cache(validade := 120)
action contra_meta(db, regiao):
    yield Banco.query(db, """
        SELECT v.mes,
               SUM(v.valor)                      AS receita,
               COALESCE(MAX(m.alvo), 0)          AS meta
        FROM vendas v
        LEFT JOIN metas m ON m.regiao = v.regiao AND m.mes = v.mes
        WHERE (? = 'Todas' OR v.regiao = ?)
        GROUP BY v.mes
        ORDER BY v.mes
    """, [regiao, regiao])

action regioes(db):
    yield ["Todas"] + (Banco.group_count(db, "vendas", "regiao")
                       >> morph r: r["regiao"])

action resumo(db, regiao):
    linhas := receita_por_mes(db, regiao)
    receita := linhas >> morph l: l["receita"] >> distill a, v: a + v 0.0
    vendas := linhas >> morph l: l["vendas"] >> distill a, v: a + v 0
    yield {
        "receita": round(receita, 2),
        "vendas": vendas,
        "ticket": round(receita / vendas, 2) given vendas bigger 0 otherwise 0.0,
        "meses": len(linhas)
    }

relay receita_por_mes, ranking_de_vendedores, mix_por_categoria
relay contra_meta, regioes, resumo

4. O painel#

dataforge
// src/painel.df
adopt Arcane.Vitrine as V
adopt ./consultas as Q

action painel(db):
    // ── Filtros, na barra lateral ──
    lado := V.lateral()
    lado.cabecalho("Filtros", 4)
    regiao := lado.escolha("Região", Q.regioes(db))
    quantos := lado.deslizante("Vendedores no ranking", 3, 15, valor := 8)
    lado.divisor()
    detalhar := lado.interruptor("Mostrar a tabela", yes)

    // ── Cabeçalho e métricas ──
    V.titulo("Análise de Vendas", icone := "📊")
    V.texto($"Região: {regiao}")

    r := Q.resumo(db, regiao)
    colunas := V.colunas(4)
    colunas[0].metrica("Receita", $"R$ {r["receita"]}")
    colunas[1].metrica("Vendas", r["vendas"])
    colunas[2].metrica("Ticket médio", $"R$ {r["ticket"]}")
    colunas[3].metrica("Meses", r["meses"])

    // ── Abas ──
    abas := V.abas(["Evolução", "Ranking", "Mix", "Dados"])

    evolucao := abas[0]
    evolucao.cabecalho("Receita contra meta")
    g := evolucao.grafico("barras", Q.contra_meta(db, regiao))
    g.eixo_x("mes")
    g.eixo_y(["receita", "meta"])
    g.titulo($"Mensal — {regiao}")
    evolucao.desenhar(g)

    linha := evolucao.grafico("linha", Q.receita_por_mes(db, regiao))
    linha.eixo_x("mes")
    linha.eixo_y("itens")
    linha.suavizar(yes)
    linha.titulo("Itens vendidos")
    evolucao.desenhar(linha)

    ranking := abas[1]
    top := Q.ranking_de_vendedores(db, regiao, quantos)
    ranking.grafico_barras_h(top, x := "vendedor", y := "receita",
                             titulo := $"Top {quantos}")
    ranking.frame(top)

    mix := abas[2]
    mix.grafico_rosca(Q.mix_por_categoria(db, regiao),
                      x := "categoria", y := "receita",
                      titulo := "Participação por categoria")

    dados := abas[3]
    given detalhar:
        linhas := Q.receita_por_mes(db, regiao)
        dados.frame(linhas)
        dados.exportar_csv(linhas, nome := $"receita-{regiao}.csv")
        dados.exportar_json(linhas, nome := $"receita-{regiao}.json")
    otherwise:
        dados.informacao("Ligue "Mostrar a tabela" na barra lateral.")

    // ── Rodapé ──
    V.divisor()
    given V.botao("Recarregar dados", tipo := "secundario"):
        V.cache.invalidar()
        V.sucesso("Cache esvaziado — as consultas serão refeitas.")

relay painel

5. Subir#

dataforge
// main.df
adopt Arcane.Vitrine as V
adopt Kiln
adopt ./src/esquema as E
adopt ./src/painel as P
adopt ./src/consultas as Q

db := E.abrir("vendas.db")

V.app("Análise de Vendas", icone := "📊", modo_tema := "automatico")
V.pagina("/", lambda: P.painel(db), titulo := "Painel")

// A MESMA aplicação serve uma API para quem quer o dado cru
kiln := V.montar()
Kiln.get(kiln, "/api/receita", lambda req: {
    "itens": Q.receita_por_mes(db, req["query"]["regiao"] ?? "Todas")
})
Kiln.use(kiln, Kiln.cors())
Kiln.use(kiln, Kiln.rate_limit(120))

V.subir(porta := 8501)
bash
dataforge vitrine dev            # http://127.0.0.1:8501
dataforge test --cobertura       # os testes, com o que falta cobrir
curl 'localhost:8501/api/receita?regiao=Sul'

6. Os testes#

Três níveis, e cada um pega um tipo de erro diferente.

dataforge
// tests/etl_test.df — a normalização
adopt Arcane.Test as T
adopt ../src/etl as ETL

action test_normaliza_regiao():
    limpa := ETL.limpar({"data": "2026-03-14", "regiao": "  SUL ",
                         "categoria": " Bebida", "vendedor": " ANA ",
                         "produto": "Café", "quantidade": "2",
                         "valor": "32,90"})
    T.assert_eq(limpa["regiao"], "Sul")
    T.assert_eq(limpa["categoria"], "bebida")
    T.assert_eq(limpa["vendedor"], "ana")
    T.assert_eq(limpa["valor"], 32.9)
    T.assert_eq(limpa["mes"], "2026-03")

action test_virgula_decimal():
    T.assert_eq(ETL.limpar({"data": "2026-01-01", "regiao": "Sul",
                            "categoria": "x", "vendedor": "y",
                            "produto": "z", "quantidade": "1",
                            "valor": "1.234,50"})["valor"], 1.2345)
dataforge
// tests/consultas_test.df — a agregação, com banco em memória
adopt Arcane.Test as T
adopt Arcane.Database as Banco
adopt ../src/esquema as E
adopt ../src/consultas as Q

db := Banco.memory()
Banco.migrate(db, E.MIGRACOES)
Banco.insert_many(db, "vendas", [
    {"data": "2026-01-05", "mes": "2026-01", "regiao": "Sul",
     "categoria": "bebida", "vendedor": "ana", "produto": "Café",
     "quantidade": 2, "valor": 100.0},
    {"data": "2026-01-06", "mes": "2026-01", "regiao": "Sul",
     "categoria": "bebida", "vendedor": "bruno", "produto": "Chá",
     "quantidade": 1, "valor": 50.0},
    {"data": "2026-02-01", "mes": "2026-02", "regiao": "Norte",
     "categoria": "mercearia", "vendedor": "ana", "produto": "Arroz",
     "quantidade": 3, "valor": 75.0}
])

action test_receita_por_mes():
    linhas := Q.receita_por_mes(db, "Todas")
    T.assert_eq(len(linhas), 2)
    T.assert_eq(linhas[0]["mes"], "2026-01")
    T.assert_eq(linhas[0]["receita"], 150.0)

action test_filtro_de_regiao():
    T.assert_eq(len(Q.receita_por_mes(db, "Norte")), 1)

action test_ranking_ordena_por_receita():
    top := Q.ranking_de_vendedores(db, "Todas", 5)
    T.assert_eq(top[0]["vendedor"], "ana")
    T.assert_eq(top[0]["receita"], 175.0)

action test_resumo():
    r := Q.resumo(db, "Todas")
    T.assert_eq(r["receita"], 225.0)
    T.assert_eq(r["vendas"], 3)
    T.assert_eq(r["ticket"], 75.0)
dataforge
// tests/painel_test.df — a tela, sem navegador
adopt Arcane.Test as T
adopt Arcane.Database as Banco
adopt Arcane.Vitrine as V
adopt ../src/esquema as E
adopt ../src/painel as P

db := Banco.memory()
Banco.migrate(db, E.MIGRACOES)
Banco.insert_many(db, "vendas", [
    {"data": "2026-01-05", "mes": "2026-01", "regiao": "Sul",
     "categoria": "bebida", "vendedor": "ana", "produto": "Café",
     "quantidade": 2, "valor": 100.0}
])

action pagina():
    P.painel(db)

action test_o_painel_monta():
    t := V.testar(pagina)
    T.assert_false(t.falhou())
    T.assert_eq(t.quantos("metrica"), 4)

action test_os_graficos_desenham():
    T.assert_contains(V.testar(pagina).html(), "<svg")

action test_o_filtro_de_regiao_funciona():
    t := V.testar(pagina)
    T.assert_contains(t.texto(), "Todas")
    t.selecionar("Região", "Sul")
    T.assert_contains(t.texto(), "Sul")

action test_o_interruptor_esconde_a_tabela():
    t := V.testar(pagina)
    antes := t.quantos("frame")
    t.marcar("Mostrar a tabela", no)
    T.assert_true(t.quantos("frame") smaller antes)

action test_a_api_responde():
    app := V.app("teste")
    V.pagina("/", pagina)
    r := V.pedir(app, "GET", "/")
    T.assert_eq(r["status"], 200)
NívelPega
ETLa vírgula decimal, o espaço em branco, a região com caixa diferente
consultaso GROUP BY errado, o filtro que não filtra, o JOIN que perde linha
painelo componente que sumiu, o filtro que não chega à consulta, o erro que a página engoliu

7. O que fazer quando fica lento#

Na ordem, e cada passo custa menos que o seguinte:

dataforge
// 1. o cache está pegando?
out V.cache.estatisticas()
// {"receita_por_mes": {"acertos": 47, "erros": 3, "taxa": 0.94}, …}

// 2. qual consulta varre a tabela?
out Banco.explain(db, "SELECT … FROM vendas WHERE regiao = ?", ["Sul"])

// 3. onde está o tempo?
out V.metricas()["media_ms"]
SintomaCausa provável
a taxa de acerto do cache é baixaa chave muda a cada execução — um argumento que é um vault novo, ou o relógio
uma consulta aparece com SCANfalta índice na coluna do WHERE
a média em ms sobe com o número de sessõescada sessão refaz a agregação; suba a validade do cache
o primeiro clique é lento e os outros nãoé o cache frio, e está funcionando

8. Em produção#

dataforge
V.configurar("producao", yes)        // esconde o stack trace na página
V.configurar("validade_sessao", 1800)
V.subir(porta := 8501, host := "127.0.0.1")

Com nginx ou Caddy na frente, para TLS e compressão — a Vitrine roda sobre o http.server, que não tem nenhum dos dois. GET /__vitrine__/saude e /__vitrine__/metricas vêm prontas para o balanceador e o monitoramento.

Com mais de um processo, a sessão precisa de um lugar comum: V.app("Painel", sessoes_em := V.sessoes_em_banco("sessoes.db")). Sem isso, dois pedidos da mesma pessoa caem em memórias diferentes.

Onde continuar#